Concurrency trong Golang
I. Giới thiệu
Lần đầu tiên mình nghe đến concurrency là khi đi phỏng vấn. Lúc đó, mình chỉ mơ hồ nghĩ rằng nó liên quan đến việc "làm nhiều thứ cùng lúc". Nhưng rồi anh interviewer hỏi: "Concurrency trong Golang hoạt động như nào", mình đứng hình, ú ớ chẳng biết trả lời sao. Sau này mình mới hiểu rõ hơn về khái niệm này. Điều thú vị là mỗi ngôn ngữ lại có cách triển khai concurrency khác nhau, với những ưu và nhược điểm riêng. Hôm nay, mình sẽ giới thiệu với bạn về Concurrency trong Golang.
II. Một vài khái niệm cơ bản
1. CPU và vai trò của thread
Để hiểu Golang concurrency, mình cần bạn nắm vài khái niệm cơ bản. Đầu tiên là CPU (Central Processing Unit), bộ xử lý trung tâm của máy tính. Nó giống như "đầu não" chịu trách nhiệm thực thi mọi lệnh trong chương trình, từ tính toán đơn giản như cộng trừ đến chạy những ứng dụng phức tạp. Không có CPU, máy tính chẳng khác gì cục sắt vô dụng.
Tuy nhiên, CPU không thể tự mình quyết định thứ tự hay cách thức thực thi các tác vụ. Nó cần một cơ chế để quản lý và phân bổ công việc. Đây là lúc thread (luồng) xuất hiện. Thread là đơn vị nhỏ nhất mà hệ điều hành có thể lên lịch để CPU thực thi. Một chương trình có thể tạo ra nhiều thread để thực hiện nhiều tác vụ cùng lúc. Ví dụ, trên máy tính của bạn, một thread có thể đang chạy trình duyệt để lướt Facebook, một thread khác mở editor để viết code, và một thread khác kiểm tra giá coin trên Pi Network.
2. Concurrency và Parallelism
Vào những năm 1990, khi CPU chỉ có 1 nhân (single-core), máy tính chỉ có thể thực thi 1 thread tại một thời điểm. Điều này gây lãng phí tài nguyên, vì nếu thread đang chờ dữ liệu từ bộ nhớ hoặc ổ cứng (I/O), CPU sẽ "ngồi chơi" dù vẫn có khả năng xử lý các tác vụ khác.
Đến khoảng năm 1995, khi các CPU hiện đại bắt đầu hỗ trợ đa luồng ở mức phần cứng, các nhà sản xuất chip như Intel và IBM đã giới thiệu khái niệm concurrency (đồng thời). Công nghệ này cho phép mỗi core CPU có thể xử lý tối đa 2 OS thread "gần như cùng lúc" bằng cách chia sẻ các thành phần bên trong CPU, như:
- ALU (Arithmetic Logic Unit): Đơn vị xử lý số học và logic.
- CU (Control Unit): Đơn vị điều khiển, quản lý luồng lệnh.
- I/O: Bộ phận xử lý đầu vào/đầu ra.
Ví dụ, một thread có thể dùng ALU để tính toán, trong khi thread còn lại dùng CU để chuẩn bị lệnh tiếp theo. Điều này làm cho CPU luôn bận rộn, giảm thời gian rảnh rỗi. Đây chính là ý tưởng cốt lõi của concurrency: làm nhiều việc cùng lúc trên cùng một tài nguyên (một core CPU).
Bạn có thể thắc mắc:1 core CPU chỉ thực thi được tối đa 2 OS thread mà không phải 3, 4 hay nhiều hơn? Thực tế việc tăng 2 OS thread không giúp CPU chạy nhanh gấp đôi, mà chỉ làm giảm thời gian rảnh rỗi của nó xuống. Con số lý tưởng được tính toán là 2, sẽ giúp tăng hiệu suất từ 24% - 30%[1]. Nhưng nếu tăng quá con số này, các tài nguyên như thanh ghi (register) hay bộ nhớ đệm (cache) sẽ bị chia nhỏ quá mức, dẫn đến xung đột và làm giảm hiệu suất.
Để tăng hiệu năng tốt hơn, cách tiếp cận hiện đại là tăng số core CPU. Ví dụ, thay vì CPU 1 core thì bạn tăng nó lên 4, lúc này bạn có thể chạy tối đa 8 OS thread đồng thời (nếu có hyper-threading). Đây chính là parallelism (song song): làm nhiều việc cùng lúc trên nhiều tài nguyên (nhiều core CPU khác nhau).
3. OS Thread và Scheduling
Một core CPU xcó thể xử lý tối đa 2 OS thread cùng lúc, nhưng hệ điều hành sẽ cho phép bạn tạo nhiều thread hơn thế – thậm chí hàng nghìn thread. Vậy làm thế nào để quản lý chúng? Đây là lúc OS scheduler (bộ lập lịch của hệ điều hành) xuất hiện. OS scheduler quyết định thread nào được chạy, thread nào phải đợi, và phân bổ thời gian cho từng thread dựa trên các yếu tố như:
- Độ ưu tiên: Thread quan trọng (ví dụ, thread xử lý giao diện người dùng) có thể được ưu tiên chạy trước.
- Tình trạng: Nếu một thread đang chờ I/O (như đọc dữ liệu từ ổ cứng), OS scheduler có thể tạm dừng thread đó và nhường CPU cho thread khác sẵn sàng.
- Thời gian: OS scheduler có thể phân bổ thời gian công bằng cho các thread để tránh tình trạng một thread chiếm CPU quá lâu.
Ví dụ, nếu bạn tạo 1000 OS thread, OS scheduler sẽ linh hoạt quyết định thread nào được chạy trên CPU tại mỗi thời điểm, thread nào phải đợi, tùy vào tình trạng hệ thống. Nhờ cơ chế này, máy tính có thể tận dụng tài nguyên hiệu quả hơn.
4. Hạn chế của OS Thread
Mặc dù OS thread rất mạnh mẽ, chúng cũng có những hạn chế đáng kể:
-
Tiêu tốn tài nguyên: Mỗi OS thread tiêu tốn bộ nhớ để lưu trữ stack (ngăn xếp) và các thông tin trạng thái khác, tuỳ vào từng hệ điều hành mà con số này là khác nhau, nhưng thường ở mức MB. Kích thước stack ở các hệ điều hành thì khác nhau, nhưng thường sẽ là con số Megabyte (ví dụ Linux, MacOS mặc định là 8MB, Windows là 1MB) và hiếm khi được điều chỉnh linh hoạt.
-
Khởi tạo chậm: Việc tạo một OS thread mới tốn thời gian, thường vài micro giây, vì hệ điều hành phải cấp phát bộ nhớ cho stack và thiết lập cấu trúc dữ liệu cần thiết.
-
Context switching tốn kém: Khi OS scheduler chuyển đổi giữa các thread (gọi là context switching), CPU phải lưu lại trạng thái của thread hiện tại (như giá trị thanh ghi, con trỏ stack) và nạp trạng thái của thread mới. Quá trình này tiêu tốn tài nguyên và gây chậm trễ, đặc biệt khi có quá nhiều thread. Nếu bạn tạo hàng nghìn thread, context switching sẽ xảy ra thường xuyên, làm máy tính chậm đi vì tài nguyên bị ngốn sạch và hệ thống quá tải với việc quản lý.
III. Từ OS Thread đến Goroutine: Cách Golang giải quyết vấn đề
Các ngôn ngữ như Java ban đầu được thiết kế để tận dụng OS thread, giao phó việc quản lý thread cho hệ điều hành. Tuy nhiên, điều này khiến Java bị giới hạn bởi hiệu suất và khả năng của OS scheduler. Nếu một ứng dụng Java cần xử lý hàng nghìn tác vụ đồng thời (ví dụ, xử lý 10.000 kết nối mạng), nó sẽ phải tạo 10.000 OS thread, dẫn đến tiêu tốn hàng GB bộ nhớ và gây quá tải context switching.
Tin vui là từ JDK 19 (ra mắt năm 2022), Java đã giới thiệu virtual thread, một giải pháp tương tự goroutine, cho phép xử lý hàng nghìn tác vụ đồng thời mà không cần tạo hàng nghìn OS thread. Tuy nhiên, virtual thread vẫn chưa được tích hợp sâu hay phổ biến như goroutine trong Golang, vốn đã được thiết kế từ đầu để tối ưu cho concurrency.
Golang được thiết kế để giải quyết vấn đề này từ đầu bằng cách giới thiệu goroutine – một đơn vị thực thi nhẹ hơn nhiều so với OS thread. Dưới đây là cách goroutine hoạt động và tại sao nó nhanh và nhẹ hơn:
1. Goroutine là gì?
Goroutine là một "luồng nhẹ" được quản lý bởi Go runtime scheduler (GRS), không phải hệ điều hành. Không giống như OS thread, goroutine không được hệ điều hành trực tiếp điều khiển mà được quản lý ở tầng ứng dụng (application level) bởi Go runtime.
2. Tại sao goroutine nhẹ hơn OS thread?
-
Kích thước nhỏ: Một goroutine chỉ tiêu tốn khoảng 2KB bộ nhớ khi khởi tạo, so với MB của một OS thread. Điều này có nghĩa là bạn có thể tạo hàng triệu goroutine mà không lo máy tính bị quá tải.
-
Tăng trưởng linh hoạt: Stack của goroutine không cố định mà có thể tăng linh hoạt khi cần (có thể lên đến 1GB). Trong khi đó, stack của OS thread thường cố định và lớn ngay từ đầu.
-
Ánh xạ vào OS thread: Goroutine được Go runtime scheduler ánh xạ vào OS thread. Một OS thread có thể chạy hàng trăm hoặc hàng nghìn goroutine. Ví dụ, với 10.000 goroutine, Go runtime chỉ cần 8 OS thread (tùy số nhân CPU và GOMAXPROCS), giảm đáng kể số OS thread so với Java (JVM), nơi mỗi tác vụ đồng thời thường cần một OS thread riêng.
3. Tại sao goroutine nhanh hơn?
-
Context switching nhẹ hơn: Context switching giữa các goroutine được thực hiện bởi Go runtime scheduler. Khi chuyển đổi, nó chỉ lưu và nạp các thông tin tối thiểu như con trỏ stack, program counter, và một vài thanh ghi, thay vì toàn bộ trạng thái như OS thread. Điều này khiến goroutine switching nhanh hơn nhiều – thường chỉ tốn 200-300 nanoseconds, nhanh hơn 5 - 17 lần so với 1-5 microseconds của OS thread switching.
-
Giảm OS thread context switching: Vì nhiều goroutine được ánh xạ vào một vài OS thread (tùy GOMAXPROCS), Go runtime scheduler tự quản lý việc chuyển đổi giữa chúng mà không cần hệ điều hành can thiệp, trừ khi OS thread bị block bởi I/O. Ví dụ, với 100 goroutine, Go runtime có thể dùng chỉ vài OS thread để chạy tất cả, giảm đáng kể số lần OS thread switching.
-
Quản lý thông minh: Go runtime scheduler biết chính xác khi nào goroutine bị block (như chờ I/O hoặc system call) vì nó kiểm soát trực tiếp vòng đời goroutine và nhận tín hiệu từ code, giúp nhanh chóng chuyển sang goroutine khác trên cùng OS thread. Ngược lại, OS scheduler quản lý toàn hệ thống nên không thể biết trước trạng thái cụ thể của thread trong ứng dụng của bạn, phải dựa vào ngắt từ kernel, dẫn đến phản ứng chậm hơn và kém tối ưu hơn.
IV. Concurrency trong Go: Mạnh mẽ trên CPU, nhưng không dành cho GPU
Concurrency trong Golang được thiết kế để tối ưu cho CPU, rất lý tưởng cho server web, chat app, hay công cụ như Docker, Kubernetes. Nhưng Golang không được xây dựng để tận dụng GPU (Graphics Processing Unit), bộ xử lý chuyên dụng cho tính toán song song. Trong khi GPU có thể xử lý hàng nghìn luồng nhỏ cùng lúc – như đồ họa, render video 3D, hay machine learning – Golang lại tập trung hoàn toàn vào CPU, bỏ qua sức mạnh này. So với Python dùng TensorFlow để khai thác GPU, như huấn luyện mạng nơ-ron nhanh hơn 10-100 lần so với CPU, Go rõ ràng không phải lựa chọn cho các tác vụ đó. Điều này cho thấy mỗi công cụ có sân chơi riêng: Go tỏa sáng trong hệ thống backend, còn GPU computing thuộc về các ngôn ngữ khác.
V. Kết luận
Nói tóm lại, Concurrency trong Golang với goroutine và channel là cách tuyệt vời để xây dựng ứng dụng hiệu suất cao, đặc biệt cho các hệ thống backend xử lý hàng triệu request đồng thời. Nó nhẹ, nhanh, sử dụng đơn gian và tiết kiệm tài nguyên. Nhưng nó không phải "thuốc tiên" – không phải bài toán nào cũng hợp với concurrency. Nếu biết tận dụng đúng cách, bạn sẽ thấy chương trình mình viết "chạy như bay" mà vẫn nhẹ nhàng với máy tính.
Muốn tìm hiểu sâu hơn? Bạn có thể đọc về worker pool, context trong Go, hoặc tự tay xây một ứng dụng nhỏ dùng goroutine thử xem. Đảm bảo sẽ thú vị lắm đấy!
✏️ System Design VN: https://fb.com/groups/systemdesign.vn
📚 Đọc thêm tài liệu khác: https://roninhub.com/tai-lieu
🎬 Youtube: https://youtube.com/@ronin-engineer
All rights reserved