Multiprocessing, Multithreading, Coroutine và PHP
Chào các bạn, hôm nay chúng ta sẽ cùng nói một chút về xử lý bất đồng bộ và các vấn đề của nó.
Ví dụ chúng ta có đoạn code sau:
$items = Http::get('https://example.com/data.json')
foreach($items as $item)
{
//do something
}
Đối với các ngôn ngữ đồng bộ như PHP, Java, đoạn code trên sẽ được thực thi tuần tự từ trên xuống dưới. Kết quả sẽ có dạng:
Kết nối đến example.com lần 1
Xử lý kết quả lần 1
Kết nối đến example.com lần 2
Xử lý kết quả lần 2
Chúng ta đều thấy có một vấn đề rằng, việc gọi tác vụ I/O (Gửi HTTP Request đến example.com) có thể tốn nhiều thời gian. Nếu có thể xử lý song song trong thời gian gửi Request thì sẽ có thể nâng performance hệ thống lên khá nhiều.
Multiprocessing
Các process được khởi tạo và quản lý bởi hệ điều hành OS. Các process độc lập với nhau về tài nguyên hệ thống (Ví dụ bộ nhớ).
Giả sử chúng ta có một chiếc máy tính với 1 core CPU, việc khởi chạy nhiều process có thể giúp xử lý đoạn code phía trên như sau:
Khởi tạo process 1
Kết nối đến example.com lần 1
Khởi tạo process 2
Kết nối đến example.com lần 2
Quay về process 1, nếu xong thì xử lý kết quả lần 1
Quay về process 2, nếu xong thì xử lý kết quả lần 2
Cách xử lý như trên gọi là Concurrency (Mình gọi là đồng thời). Hiện tại hầu hết máy tính đều có nhiều hơn 1 core CPU, nên các tác vụ process có thể được khởi tạo trên nhiều CPU khác nhau. Khi đó chúng ta sẽ có Xử lý song song (Parallel). Mô tả khác nhau cơ bản giữa Concurrency và Parallel các bạn có thể tham khảo hình dưới đây:
Vấn đề với process là việc khởi tạo, switching context giữa các process là tương đối tốn tài nguyên (Vì các process là độc lập). Đối với PHP, chúng ta có PHP-FPM và process pool để phần nào giảm tải việc này, tuy nhiên, vẫn có những cách tối ưu hơn để giải quyết vấn đề process.
Multithreading
Các thread cũng được khởi tạo và quản lý lập lịch bởi OS. Tuy nhiên, khác với process, các thread trên cùng process sẽ dùng chung một không gian bộ nhớ.
Đối với thread, hầu hết các tác vụ đều là Concurrency (Giống như chúng ta đã đề cập bên trên). OS sẽ chịu trách nhiệm cấp phát tài nguyên, lập lịch và switching context giữa các thread. Ví dụ với đoạn code phía trên, OS sẽ slice các khoảng thời gian nhỏ, ví dụ 100, 200ms để chuyển đổi giữa các thread khác nhau. Một process có thể khởi chạy nhiều thread
Vấn đề với thread là việc sử dụng chung tài nguyên có thể dẫn đến những tranh chấp nếu chúng ta bất cẩn. Khởi tạo nhiều thread trên nhiều process, switching context giữa các thread cũng tương đối tốn kém tài nguyên.
Coroutine
Coroutine là kết hợp giữa co-operate và routine. Ở đây các tác vụ sẽ được coi là routine, và được chạy Concurrency. Coroutine cũng giống như thread, khi các routine có thể chia sẻ chung tài nguyên, và có cơ chế giao tiếp với nhau.
Khác biệt lớn nhất giữa coroutine và process/thread, là coroutine được sinh ra và quản lý hoàn toàn bởi runtime của ngôn ngữ lập trình (Ví dụ Go / Java / PHP). Việc switching context giữa các routine được xử lý tự động bởi runtime, hoặc developer cũng có thể kiếm soát quá trình này.
Ví dụ ở đầu của chúng ta sẽ có một chút thay đổi:
$items = Http::get('https://example.com/data.json')
//yield (nhường quyền switch context lại cho runtime)
foreach($items as $item)
{
//do something
}
$item2s = Http::get('https://example.com/data2.json')
//yield (nhường quyền switch context lại cho runtime)
foreach($item2s as $item2)
{
//do something
}
Các bạn sẽ thấy có những đoạn yield. Đó chính là lúc runtime (hoặc developer) đánh dấu việc có thể nhường lại quyền chuyển đổi giữa các routine để tiếp tục khởi chạy các routine khác. Chúng ta có thể diễn giải:
Khởi tạo routine 1
Kết nối đến example.com lần 1
Yield, routine 1 nhường quyền lại runtime
Khởi tạo routine 2
Kết nối đến example.com lần 2
Yield routine 2 nhường quyền lại runtime
Quay về routine 1, nếu xong thì xử lý kết quả lần 1
Quay về routine 2, nếu xong thì xử lý kết quả lần 2
Đối với cá nhân mình, coroutine có nét khá giống với callback handler trên JS.
Ở đây việc lập lịch, đã không còn nằm trong tay OS nữa mà là runtime. Điều này dẫn chúng ta đến một khái niệm khá phổ biến: Non-blocking I/O. Các tác vụ I/O xuất hiện rất nhiều trong các ứng dụng: Khởi tạo kết nối database, gửi HTTP Request … Về cơ bản, những tác vụ chúng ta gửi đầu vào (Input) và chờ đợi đầu ra (Output) đều có thể gọi là tác vụ I/O. Việc sử dụng Coroutine sẽ yêu cầu các thư viện, các đoạn code hỗ trợ Non-blocking (cơ chế trả quyền switch context cho runtime), nếu không chúng ta sẽ ko có Concurrency và quay về với việc chạy đồng bộ truyền thống.
Lợi thế lớn của coroutine đó là việc được quản lý bởi runtime, nên chi phí tài nguyên tốn rất ít. Một coroutine chỉ tốn khoảng 2KB để khởi tạo (Thread có thể được phân phối từ 1 đến vài MB khác nhau). Chúng ta cũng có thể khởi tạo hàng nghìn coroutine trên một thread, giúp ứng dụng của chúng ta có hiệu suất cao.
Vấn đề với coroutine là việc share chung tài nguyên cũng cần tốn thêm quản lý khi lập trình. Việc yield giữa các routine cũng cần cẩn trọng để tránh có những kết quả không mong muốn.
Đối với PHP, nếu muốn chuyển sang coroutine, ví dụ Swoole, chi phí chuyển đổi cũng lớn vì sẽ phải thay thế các thư viện sang non-blocking IO.
Bất đồng bộ PHP
PHP đã từ bỏ khái niệm thread. Chúng ta sẽ dựa hoàn toàn vào Process để triển khai Concurrency. Bên cạnh thread, coroutine PHP cũng phát triển với rất nhiều thư viện hỗ trợ như Swoole, amp, roadrunner. Gần đây nhất, trong phiên bản PHP 8.1, PHP đã chính thức hỗ trợ Coroutine dưới tên Fiber (Trước đây, để làm Coroutine với PHP thuần túy thường phải tận dụng Generators, khá khó kiểm soát).
PHP mô tả Fiber như là một kỹ thuật cấp thấp để quản lý các các tác vụ song song. Một ví dụ về Fiber trong PHP như sau:
$fiber = new Fiber(function (): void {
$valueAfterResuming = Fiber::suspend('after suspending');
// …
});
$valueAfterSuspending = $fiber->start();
$fiber->resume('after resuming');
Như các bạn thấy, chúng ta có thể dễ dàng suspend và resume một Fiber thông qua các API mà PHP cung cấp. Điều này sẽ là một lợi thế rất lớn, mở ra những cách khai thác mới lạ cho PHP như xây dựng Eventloop, xử lý WebSocket trên PHP. Cám ơn các bạn đã theo dõi bài viết, hy vọng nó đã cung cấp cho các bạn nhiều góc nhìn về xử lý song song trên PHP.