Post

SWE #1: Concurrency (Part 1) - Chúc mừng 30/4 - 1/5

SWE #1: Concurrency (Part 1) - Chúc mừng 30/4 - 1/5

Summary

Xin chào, nhân dịp nghỉ lễ 30/4 - 1/5. Tiện ôn lại chút kiến thức về concurrency, mình sẽ viết một bài về concurrency trong Golang. Bài viết này sẽ giúp các bạn hiểu rõ hơn về concurrency, các khái niệm liên quan và cách sử dụng goroutine và channel trong Golang.

Concurrency và Parallelism

Như chúng ta đã biết, thời gian đầu CPU chỉ có một nhân (core) duy nhất, khi đó các ngôn ngữ sẽ theo mô hình lập trình tuần tự, điển hình là ngôn ngữ C.

Ngày nay, với sự phát triển của công nghệ đa vi xử lý, để tận dụng tối đa sức mạnh của CPU, mô hình lập trình song song hay multi-threading ra đời. Mô hình này cho phép nhiều luồng (thread) chạy song song trên nhiều nhân CPU khác nhau, giúp tăng hiệu suất xử lý.

Concurrency là gì ?

Concurrency là một khái niệm trong lập trình cho phép nhiều tác vụ (task) chạy đồng thời, nhưng không nhất thiết phải chạy song song. Concurrency giúp chúng ta tổ chức và quản lý các tác vụ một cách hiệu quả hơn, giúp tăng khả năng mở rộng và giảm độ trễ trong ứng dụng.

Trái ngược với xử lý tuần tự (sequential processing), trong đó các tác vụ được thực hiện lần lượt, concurrency cho phép nhiều tác vụ được thực hiện đồng thời, nhưng không nhất thiết phải chạy song song.

“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.” - Rob Pike

Đơn giản thì lấy ví dụ thế này, giả sử anh Virrus đang muốn tán 3 em gái (Ngọc Kem, Emma, Bọt iu) một lúc. Nếu như xử lí theo mô hình tuần tự, anh ta sẽ phải tán xong em gái thứ nhất rồi mới tán tiếp em gái thứ hai, và cuối cùng là em gái thứ ba.

Concurrency

Còn đối với mô hình xử lý đồng thời, anh Virrus có thể làm cả 3 việc cùng một lúc, nhưng không nhất thiết phải hoàn thành cả 3 việc trong cùng một thời điểm. Anh ta có thể tán em gái thứ nhất một chút, rồi chuyển sang tán em gái thứ hai, rồi lại quay lại tán em gái thứ nhất, và cuối cùng là tán em gái thứ ba. Wow quả là một người phân chia thời gian rất tốt. Concurrency

Có lẽ với ví dụ trên, các bạn đã hiểu được khái niệm concurrency là gì rồi đúng không? Nhưng liệu rằng các bạn có thắc mắc làm sao để có thể xử lý đồng thời như vậy? Máy tính đã làm cách nào?

Tất cả các chương trình đang chạy trong máy tính của chúng ta đều do hệ điều hành (Operating System) quản lý. Với mỗi chương trình đang chạy được gọi là một process(tiến trình) và được cấp cho một process id (PID) để hệ điều hành dễ dàng quản lí.

Các bạn có thể kiểm tra các process đang chạy trên máy tính của mình bằng lệnh ps -ef trong terminal.

Các tác vụ của tiến trình sẽ được CPU Core (Nhân CPU) xử lý. Như câu nói của Rob Pike, ông đã sử dụng từ dealing (phân chia xử lý) để nói đến khái niệm concurrency. Thật vậy, tại mỗi thời điểm nhân CPU chỉ có thể thực hiện được duy nhất một tác vụ. Nhưng nhân CPU không bao giờ đợi xử lý xong một tác vụ rồi mới xử lý tác vụ khác. Mà nó sẽ chia các tác vụ lớn thành các tác vụ nhỏ hơn sắp xếp xen kẽ lần nhau.

Sau đó, nhân CPU sẽ tận dụng thời giản rảnh của tác vụ này để đi làm một tác vụ khác, lúc thì làm tác vụ nhỏ này, lúc thì làm tác vụ nhỏ khác (Giống như cách Anh Virrus) tán gái. Như vậy, ta sẽ cảm thấy máy tính xử lý được nhiều việc tại cùng một thời điểm. Nhưng bản chất bên dưới nhân CPU thì nó chỉ có thể thực thi một tác vụ nhỏ trong tác vụ lớn tại thời điểm đó. Chỉ vì thời gian chuyển giữa các tác vụ quá nhanh làm chúng ta tưởng rằng tất cả đều đang được xử lí trong cùng 1 lúc.

Parallelism là gì ?

Xử lý song song là khả năng xử lý nhiều tác vụ khác nhau tại cùng một thời điểm, các tác vụ này hoàn toàn độc lập với nhau.

Tất nhiên, xử lý song song chỉ có thể thực hiện trên máy tính có số nhân lớn hơn 1. Thay vì một nhân CPU chúng ta có thể xử lý một tác vụ nhỏ tại một thời điểm thì khi só nhân CPU nhiều hơn chúng ta có thể xử lý các tác vụ song song với nhau cùng lúc trên các nhân CPU.

Parallelism is about doing lots of things at once-Rob Pike

Các bạn có thể quan sát, mô hình sau về xử lý song song:

Concurrency

Trong thực tế, trên mỗi nhân của CPU vẫn xảy ra quá trình xử lý đồng thời miễn là một thời điểm không có xảy ra việc xử lý cùng một tác vụ trên hai nhân CPU khác nhau. Chi tiết mô hình trên sẽ như sau:

Concurrency

Ok, vậy là chúng ta đã đi qua một số khai niệm cơ bản nhất về concurrency. Hãy bắt tay vào thực hành thôi nào

Concurrency trong Golang

Trước khi tìm hiểu cách mà Golang xử lý concurrency, chúng ta hãy nhắc lại một số khái niệm về tiến trình (process) và luồng (thread) trong hệ điều hành.

Process

Tiến là đơn giản là một chương trình đang chạy trong máy tính. Khi ta mở trình duyệt web đây được xem là một tiến trình. Khi ta viết một chương trình lập trình như C, Java, … sau đó tiến hành biên dịch và chạy chương trình thì đây cũng là một tiến trình. Hệ điều hành sẽ cấp cho chương trình một không gian bộ nhớ nhất định, một PID nhất định, … Mỗi tiến trình có ít nhất một luồng chính (main thread) để chạy chương trình. Khi luồng này ngừng hoạt động tương ứng với việc chương trình bị tắt

Thread

Thread (Không phải thread trong thread city nhé) hay được gọi là tiểu tiến trình là một luồng trong tiến trình đang chạy. Các luồng được chạy song song trong mỗi tiến trình và có thể truy cập đến vùng nhớ được cung cấp bới tiến trình, các tài nguyên của hệ điều hành.

Concurrency

Các thread trong process sẽ được cấp phát riêng một vùng nhớ stack để lưu các biến local (biến riêng) của thread đó. Ngoài ra các thread chia sẻ chung vùng nhớ heap trong process.

Khi process tạo quá nhiều thread sẽ dẫn tới tình trạng stack overflow. Khi các thread sử dụng chung vùng nhớ sẽ dễ gây ra hiện tượng race condition (Chúng ta sẽ tìm hiểu ở phần sau). Và vì CPU chỉ thực hiện được một tác vụ tại một thời điểm, nên thao tác để chuyển qua xử lý tác vụ khác gọi là context switching.

Goroutines và system threads

Goroutines là một đơn vị concurrency trong ngôn ngữ Go. Việc khởi tạo Goroutines sẽ ít tốn chi phí hơn khởi tạo thread so với các ngôn ngữ khác. Goroutines được quản lý bởi Go runtimes trong khi các ngôn ngữ khác được quản lý bởi hệ điều hành.

Về cơ bản, Goroutines và System threads không giống nhau.

Đầu tiên, system threads sẽ có một kích thước vùng stack cố định (thông thường khoảng 2MB). Vùng nhớ này chủ yếu được dùng để lưu những tham số, biến cục bộ và địa chỉ trả về khi ta gọi hàm.

Từ đây dẫn tới 2 vấn đề:

  • Stack overflow với những chương trình gọi đệ quy sâu

  • Lãng phí tài nguyên đối với những chương trình đơn giản.

Và giải pháp cho vấn đề này chính là cấp phát linh hoạt cho vùng nhớ stack:

  • Một Goroutines sẽ được bắt đầu bằng một vùng nhớ nhỏ (khoảng 2-4KB)

  • Khi gọi đệ quy sâu (bộ nhớ stack không còn đủ) Goroutunes sẽ tự động tăng kích thước stack (tối đa 1GB)

  • Mặt khác, chi phí của việc khởi tạo là nhỏ, ta có dễ dàng giải phóng hàng ngàn goroutines.

Bên cạnh đó, Goruntime có riêng cơ chế đinh thời cho Goroutines, nó dùng một số kỹ thuật để ghép M goroutines lên N thread của hệ điều hành. Cơ chế định thời Goroutines tương tự với cơ chế định thời của hệ điều hành nhưng chỉ ở mức chương trình. Biến runtime.GOMAXPROCS quy định số lượng thread hiện thời chạy trên các Goroutines.

Ví dụ Goroutine

1
2
3
4
func main() {
    go fmt.Println("Print goroutines")
    fmt.Println("Main goroutines")
}

Để khởi tạo một Goroutines, chúng ta dùng từ khóa go trước function mà chúng ta muốn đặt goroutines.

Nhưng khi chạy chương trình, các bạn sẽ chỉ thấy mỗi câu “Main goroutines” được in ra. Vậy câu “Print goroutines” đi đâu rồi ?

Như ta đã biết, main thread dừng thì chương trình sẽ dừng. Hàm main cũng là một goroutines và chạy đồng thòi cùng hàm fmt.Println. Nên có trường hợp hàm main chạy xong và dừng trước hàm fmt.Println. Nêm xảy ra tình huống như trên. Cách để sửa ta sẽ đặt một time.Sleep(2 * time.Second) (2 giây) để đợi Print chạy xong rồi mới end main

1
2
3
4
5
func main() {
    go fmt.Println("Print goroutines")
    fmt.Println("Main goroutines")
    time.Sleep(2 * time.Second)
}

Và tất nhiên, trong thực tế không ai có thể xác định được một hàm chạy bao lâu để mà đặt time.Sleep, chúng ta sẽ sử dụng các cơ chế như Mutex, Channel hay Wait Group (Ở phần sau) để xử lý

Conclusion

Bài viết cũng đã khá dài, mình sẽ tạm kết thúc phần 1 ở đây, trong những bài tiếp theo chúng ta sẽ đi tới những ví dụ phức tạp hơn về cách xử lý race-condition cũng như sử dụng channel để chặn các goroutines, …

Tài liệu tham khảo

  • Advanced Go Book (ZaloPay Teams)
  • Concurrency Golang (Go dev)
  • Concurrency Patterns (200 Lab)
This post is licensed under CC BY 4.0 by the author.