Concurrency là gì?

Concurrency (tính đồng thời) là khả năng xử lí nhiều tác vụ cùng 1 lúc. Ví dụ, khi bạn đang lướt web, có thể bạn đang download file trong khi đang nghe nhạc, đồng thời đang scroll trang. Nếu trình duyệt không thể thực hiện chúng cùng 1 lúc, bạn sẽ phải đợi đến khi mọi file download xong, mới có thể nghe nhạc, rồi mới có thể scroll. Điều này nghe rất khó chịu phải không nào?
Vậy làm thế nào để 1 máy tính có CPU 1 nhân có thể xử lí nhiều tác vụ cùng lúc, trong khi 1 nhân CPU chỉ có thể xử lí 1 việc tại 1 thời điểm? Concurrency tức là xử lí 1 tác vụ tại 1 thời điểm, nhưng CPU không xử lí hết 1 tác vụ rồi mới đến tác vụ khác, mà sẽ dành 1 lúc cho tác vụ này, 1 lúc cho tác vụ kia. Do vậy, chúng ta có cảm giác máy tính thực hiện nhiều tác vụ cùng 1 lúc, nhưng thực chất chỉ có 1 tác vụ được xử lí tại 1 thời điểm.
Cùng xem biểu đồ dưới để rõ hơn về cách CPU phân bố khi chúng ta sử dựng web ở ví dụ trên.

 

alt text
 


Từ biểu đồ trên, chúng ta có thể thấy rằng, CPU 1 nhân phân chia thời gian làm việc dựa trên độ ưu tiên của cùng tác vụ. Ví dụ, khi đang scroll trang, việc nghe nhạc sẽ có độ ưu tiên thấp hơn, nên có thể nhạc của bạn sẽ bị dừng do đường truyền kém, nhưng bạn vẫn có thể kéo trang lên xuống.

Parallelism là gì?

Ở phần trên, chúng ta chỉ nói về CPUí 1 nhân, nhưng nếu máy tính có CPU nhiều nhân thì sao? 
Với ví dụ lượt web, CPU 1 nhân cần chia thời gian sử dụng cho các tác vụ. Còn với CPU nhiều nhân, chúng ta có thể xử lí nhiều tác vụ cùng lúc trên các nhân khác nhau ?

alt text
 


Cơ chế xử lí nhiều tác vụ song song với nhau được gọi là parallelism. Khi CPU có nhiều nhân, chúng ta có thể sử dụng nhiều nhân để xử lí nhiều thứ 1 lúc.

Concurrency vs Parallelism

Go khuyến khích việc sử dụng goroutines chỉ trên 1 nhân, tuy nhiên, chúng ta cũng có thể chỉnh sửa để goroutines chạy trên các nhân khác nhau. Có thể coi goroutines là 1 function trong go.
Có vài điểm khác biệt giữa concurrency và parallelism. Concurrency xử lí nhiều 1 tác vụ 1 lúc, còn parallelism là thực hiện nhiều tác vụ cùng 1 lúc. Parrallism không phải lúc nào cũng tốt hơn concurrency. Chúng ta sẽ nghiên cứu sâu hơn về vấn đề này trong những bài viết tới.
Để hiểu rõ hơn về concurrency trong go và cách sử dụng chúng trong code, trước hết chúng ta cần hiểu hơn về 1 tiến trình xử lí (process) của máy tính.

Proccess của máy tính là gì?

Khi viết 1 chương trình máy tính bằng ngôn ngữ như C, Java, hay Go, thì về cơ bản chúng cũng chỉ là file văn bản. Còn máy tính chỉ hiểu code dạng binary. Do vậy chúng ta cần biên dịch code ra ngôn ngữ máy.
Khi 1 chương trình đã được biên dịch được gửi đến OS (hệ điều hành) để xử lí, hệ điều hành sẽ cấp cho 1 không gian bộ nhớ, 1 bộ đếm chương trình, PID (process id) và nhiều thứ khác. 1 process có ít nhất 1 thread được gọi là thread chính, và thread chính có thể tạo nhiều nhiều thread khác. Khi thread chính hoàn thành, process sẽ biến mất.
Nói chung, chúng ta có thể hiểu rằng 1 process sẽ bao gồm đoạn code đã được biên dịch, bộ nhớ, các tài nguyên của hệ điều hành và nhiều thứ khác được cung cấp cho thread.

Thread là gì?

Thread giống như process, nhưng nó là 1 thực thể nằm trong process. Thread có thể truy câp đến bộ nhớ được cung cấp bởi process, tài nguyên của hệ điều hành và các thứ khác.
Khi thực thi 1 đoạn code, thread sẽ lưu biến (dữ liệu) và trong 1 vùng nhớ được gọi là stackStack được tạo ra vào thời gian biên dịch, và nó thường có kích cỡ cố định, từ 1-2MB.Stack của 1 thread chỉ được sử dụng trong thread đó. Heap là 1 thuộc tính của process và nó có thể được truy cập từ mọi thread trong process đó. Heap là 1 vùng nhớ được chia sẽ giữa các thread với nhau.
Vậy khi nào thread được sử dụng?
Ví dụ, khi lướt web, phải có 1 đoạn code nào đó hướng dẫn cho hệ điều hành xử lí. Điều đó có nghĩa là chúng ta đang tạo ra 1 process. Khi 1 tab mới được mở ra, trong khi chúng ta đang sử dụng, proccess cho tab sẽ bắt đầu tạo ra nhiều thread khác nhau cho các hành động khác nhau (như download, nghe nhạc, ..).

alt text
 


Ở ảnh trên, có thể thấy ngay rằng Google Chrome đang sử dụng nhiều process cho các tab và các tác vụ ngầm. Mỗi process có ít nhất 1 thread, ở ví dụ trên, 1 process của Chrome thường có đến hơn 10 thread.
Ở những phần trên, chúng ta đã nói về việc xử lí nhiều việc và thực hiện nhiều viêc 1 lúc. Việc ở đây là những hành động thực hiện bởi 1 thread. Do vậy, khi nhiều việc được được thực hiện theo kiểu đồng thời hoặc song song, tương ứng sẽ có nhiều thread chạy đồng thời hoặc song song, hay còn gọi là multi-threading. Khi nhiều thread được sinh ra bởi 1 process, và 1 thread gây tràn bộ nhớ (memory leak), sẽ khiến cho process bị đơ, và chúng ta phải dùng task manager để tắt nó.
Khi nhiều thread cùng chạy đồng thời hoặc song song, do có nhiều thread cùng chia sẽ dữ liệu chung, nên chúng cần phối hợp nhịp nhàng để làm sao chỉ 1 thread có thể truy cập vào 1 dữ liệu cụ thể tại 1 thời điểm. OS thread được quản lí bởi kernel, 1 vài thread được quản lí bởi môi trường chạy ngôn ngữ.
Nếu nhiều thread cùng thay đổi 1 dữ liệu tại cùng 1 thời điểm , thì kết quả trả về khả năng cao không được như mong đợi, và gây ra race condition.

alt text
 

Concurrency trong Go

Ở các ngôn ngữ truyền thống, ví dụ như Java, sẽ có 1 thread class để sử dụng khi cần tạo nhiều thread trong 1 process. Tuy nhiên, Go không có những cấu trúc cú pháp OOP truyền thống, mà thay vào đó, Go cung cấp từ khóa go để tạo ra các goroutines. Khi chúng ta gọi 1 function với từ khóa go ở trước, nó sẽ trở thành 1 goroutines.
Khi chúng ta chạy 1 chương trình, go runtime sẽ tạo ra 1 vài thread trên 1 nhân. 1 thread sẽ thực hiện 1 goroutine, và khi goroutine đó bị khóa, 1 goroutine khác sẽ được thế vào và thực hiện trên thread đó.
Go khuyến khích việc sử chạy các goroutines chỉ trên 1 nhân CPU, tuy nhiêu nếu cần sử dụng nhiều nhân hơn, chúng ta có thể sử dụng biến môi trường GOMAXPROCS hoặc sử dụng function runtime.GOMAXPROCS(n) với n là số nhân muốn sử dụng. Tuy nhiên, đôi khi bạn sẽ cảm thấy việc chạy trên nhiều nhân lại khiến chương trình của chúng ta chậm hơn. Trong thực tế, các chương trình mà sử dụng nhiều thời gian để tương tác giữa các kênh (channels) hơn là việc tính toán, sẽ cho hiệu suất tồi hơn khi sử dụng nhiều nhân CPU, OS process và thread.

Threads vs Goroutines

Thread

Goroutines

OS thread được quản lí bởi kernel và phụ thuộc vào phần cứng.

Goroutines được quản lí bởi go runtime và không phụ thuộc vào phần cứng.

OS thread thường có kích cỡ stack cố định từ 1-2MB

Goroutines có kích cỡ stack từ 8KB

Kích cỡ stack được xác định trong thời gian compile và không được tăng thêm.

Kích cỡ stack có thể tăng đến 1GB

Khó tương tác giữa các thread. Có độ trễ lớn trong việc tương tác giữa các thread.

Goroutine sử dụng channels để tương tác với nhau với độ trễ thấp

Thread có định danh.

Goroutines không có định danh

Thread mất nhiều thời gian để tạo và xóa, do 1 thread cần nhiều tài nguyên từ hệ điều hành, và trả lại nó khi hoàn thành công việc.

Goroutines được tạo và xóa bởi go runtime. Các hành động này chạy nhẹ hơn nhiều so với thread, do go runtime luôn duy trì 1 lượng thread for goroutines.

Để hiểu được sức mạnh của go concurrency, thử tưởng tượng chúng ta có 1 web server, mà phải xử lí 1000 request mỗi phút. Nếu phải chạy mỗi request đồng thời, tức là chúng ta cần tạo ra 1000 thread hoặc chia chúng ra các process khác nhau. Đây là cách mà Apache server quản lí các request. Nếu 1 OS thread chiếm 1MB cho stack của mỗi thread, thì tổng cộng chúng ta sẽ mất đến 1GB RAM. Apache cung cấp ThreadStackSize để quản lí dung lượng cho stack của mỗi thread, tuy nhiên nó cũng không giải quyết được triệt để vấn đề.

Còn với goroutines, do dung lượng stack tăng tự động, chúng ta có thể chạy đến 1000 goroutines mà không xảy ra vấn đề nào. Tuy nhiên, nếu có 1 chương trình chạy 1 hàm đệ quy cần nhiều bộ nhớ hơn, go có thể tăng stack lên đến 1GB (điều mà có lẽ hiếm khi xảy ra, trừ khi với vòng lặp for{} chạy vô tận). Để hiểu được sức mạnh của go concurrency, thử tưởng tượng chúng ta có 1 web server, mà phải xử lí 1000 request mỗi phút. Nếu phải chạy mỗi request đồng thời, tức là chúng ta cần tạo ra 1000 thread hoặc chia chúng ra các process khác nhau. Đây là cách mà Apache server quản lí các request. Nếu 1 OS thread chiếm 1MB cho stack của mỗi thread, thì tổng cộng chúng ta sẽ mất đến 1GB RAM. Apache cung cấp ThreadStackSize để quản lí dung lượng cho stack của mỗi thread, tuy nhiên nó cũng không giải quyết được triệt để vấn đề.

Goroutines bị block khi gặp 1 trong những trường hợp sau:

  • network input
  • dùng time sleep
  • dùng channel
  • sử dụng sync package

Channels đóng vai trò quan trọng khi sử dụng goroutines. Chúng giúp ngăn ngừa race condition và việc truy cập không đúng đến các dữ liệu được chia sẻ tương tự như ở thread.

Bài viết được dịch từ https://medium.com/rungo/achieving-concurrency-in-go-3f84cbf870ca .