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ẽ kể cho bạn về Concurrency trong Golang, để bạn không còn "ú ớ" như mình ngày đó nữa nhé.
II. Một vài khái niệm cơ bản
Để 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.
Nhưng CPU không làm việc một mình – nó cần một cơ chế để tổ chức và xử lý nhiều tác vụ khác nhau. Đây là lúc thread xuất hiện. Thread (luồng) là đơn vị nhỏ nhất mà CPU có thể lên lịch thực thi. Một chương trình có thể có nhiều thread để làm nhiều việc cùng lúc, ví dụ như máy tính của bạn sẽ có: một thread mở Facebook ngắm gái, một thread mở editor code, và một thread xem tình hình Pi Network.
Trước những năm 1990, với CPU 1 nhân, máy tính chỉ chạy được 1 thread tại một thời điểm. Điều này gây lãng phí tài nguyên, vì khi thread đang chờ dữ liệu từ bộ nhớ hoặc ổ cứng, CPU phải "ngồi chơi" dù vẫn có khả năng làm việc khác. Đến khoảng năm 1995, khi các hệ điều hành hiện đại như Windows NT hay Unix phát triển, khái niệm concurrency được đưa vào. Với mỗi nhân của CPU, có thể chạy tối đa 2 OS thread cùng lúc. Việc này giúp tận dụng các thành phần trong CPU như ALU (Arithmetic Logic Unit) để tính toán, CU (Control Unit) để điều phối lệnh, hay bộ phận truy xuất dữ liệu từ bộ nhớ. Một thread có thể dùng ALU để tính toán, trong khi thread kia dùng CU để chuẩn bị lệnh tiếp theo, giúp CPU luôn bận rộn và giảm thời gian rảnh rỗi. Đây chính là ý tưởng cơ bản của concurrency: làm nhiều việc cùng lúc trên cùng một tài nguyên. Nhưng bạn có thể thắc mắc: tại sao lại là 2 thread mà không phải 3, 4 hay 5? Lý do là nếu tăng lên 3, 4 thread trên 1 nhân, tài nguyên như thanh ghi hay bộ nhớ đệm bị chia nhỏ quá mức, gây xung đột và làm giảm hiệu suất thay vì tăng. Để tăng hiệu năng tốt hơn, cách tiếp cận hiện đại là tăng số nhân CPU – ví dụ, với 8 nhân, bạn có thể chạy tối đa 16 OS thread đồng thời, đó chính là parallelism: làm nhiều việc cùng lúc trên nhiều tài nguyên..
Tại một thời điểm, một nhân CPU chỉ có thể xử lý tối đa 2 OS thread cùng lúc. Tuy nhiên, hệ điều hành cho phép bạn tạo nhiều thread hơn thế và quản lý chúng qua cơ chế scheduling – lập lịch thực thi. Bộ lập lịch không chỉ đơn giản là chạy lần lượt mà còn thông minh hơn: nó có thể ưu tiên thread quan trọng, tạm dừng thread đang chờ I/O để nhường cho thread sẵn sàng, hoặc phân bổ thời gian dựa trên độ ưu tiên. Ví dụ, nếu bạn tạo 1000 OS thread, hệ điều hành sẽ linh hoạt quyết định thread nào được chạy, thread nào phải đợi, tùy vào tình trạng hệ thống lúc đó. Nhờ vậy, máy tính của bạn có thể tận dụng tài nguyên hiệu quả hơn.
Nhưng cái gì cũng có giá. Mỗi OS thread ngốn khoảng 1-2MB bộ nhớ (và có thể tăng khi cần) – thậm chí còn tăng nếu chương trình phức tạp – và việc khởi tạo chúng cũng mất kha khá thời gian. Chưa kể, context switching – quá trình chuyển đổi giữa các thread – lại khá tốn tài nguyên. Mỗi lần chuyển, CPU phải lưu lại toàn bộ 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, làm tăng tải xử lý và gây chậm trễ. Nếu bạn tạo quá nhiều thread, máy tính sẽ bắt đầu ì ạch vì tài nguyên bị ngốn sạch, còn hệ thống thì quá tải với việc quản lý.
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. Nhưng chính vì phụ thuộc vào giải pháp này, nó bị giới hạn bởi hiệu suất và khả năng của hệ điều hành. Tin vui là từ JDK 19, Java đã giới thiệu virtual thread – một giải pháp nhẹ hơn, lấy cảm hứng từ goroutine của Go. Dù vậy, nó vẫn chưa được tích hợp sâu hay phổ biến như cách Golang xây dựng từ đầu.
III. Concurrency trong golang
Với golang nó được thiết kế để tự tạo ra một loại thread riêng gọi là goroutine, do chính runtime của go điều khiển. Cơ chế này dựa trên mô hình M:N mapping: nghĩa là hàng nghìn (M) goroutine được ánh xạ lên một số lượng nhỏ (N) OS thread. Điều này giúp Golang tận dụng tài nguyên hiệu quả hơn, không bị giới hạn bởi cách hệ điều hành quản lý thread.
Một điểm nổi bật là goroutine chỉ chiếm khoảng 2KB bộ nhớ ban đầu (và có thể tăng đến 1GB khi cần) , trong khi OS thread thường ngốn đến 2MB, tức là nhẹ hơn gấp 1000 lần. Tại sao lại có sự khác biệt lớn như vậy? OS thread cần một stack lớn – thường là 1-2MB – để lưu trữ toàn bộ ngữ cảnh thực thi, phòng trường hợp chương trình gọi đệ quy sâu hoặc xử lý tác vụ phức tạp. Còn goroutine thì bắt đầu với stack siêu nhỏ, chỉ khoảng 2KB, và runtime của Go sẽ tự động mở rộng stack nếu cần, nhưng hiếm khi vượt quá mức tối thiểu. Chính cách quản lý stack linh hoạt này giúp goroutine nhẹ nhàng hơn rất nhiều.
Việc quản lý goroutine được giao cho GRS (Goroutine Scheduler) – một hệ thống thông minh nằm trong runtime của Go. Không như OS scheduler phải xử lý hàng loạt thread từ nhiều chương trình khác nhau, GRS chỉ tập trung vào goroutine trong ứng dụng của bạn. Điều này giúp nó giảm thiểu context switching – quá trình chuyển đổi giữa các thread – một cách hiệu quả. GRS biết chính xác khi nào goroutine bị block (ví dụ chờ I/O) để nhường CPU cho goroutine khác, trong khi OS scheduler thường chậm hơn vì phải cân bằng tài nguyên cho cả hệ thống. Kết quả là hiệu suất của Golang vượt trội hơn khi xử lý đa luồng.
Vậy goroutine là gì? Nó đơn giản là một hàm hoặc phương thức chạy đồng thời với các phần khác của chương trình, và cách dùng thì cực kỳ dễ. Bạn chỉ cần thêm từ khóa go
trước hàm là xong. Ví dụ, thay vì gọi processData()
, bạn viết go processData()
– thế là hàm này chạy song song ngay. Hoặc như thế này:
func printHello() { fmt.Println("Hello from goroutine!")
} func main() { go printHello() // Chạy song song fmt.Println("Hello from main!")
}
Chạy đoạn code trên, bạn sẽ thấy hai dòng in ra không theo thứ tự cố định, vì printHello()
giờ là một goroutine riêng. Đơn giản vậy thôi, nhưng sức mạnh thì không nhỏ – bạn có thể tạo hàng triệu goroutine mà máy vẫn "thở" bình thường.
Nhưng nếu goroutine chạy độc lập như vậy, làm sao chúng trao đổi thông tin với nhau được? Đây là lúc mình sẽ gợi mở đến một khái niệm khác: channel.
IV. Channel - cơ chế giao tiếp giữa goroutine
Khi bạn có hàng tá goroutine chạy song song, vấn đề lớn nhất là làm sao để chúng phối hợp nhịp nhàng mà không "giẫm chân" nhau. Nếu không có cách giao tiếp rõ ràng, điều gì sẽ xảy ra? Hãy tưởng tượng bạn đang ở quầy tính tiền siêu thị. Một nhân viên quét mã sản phẩm, còn một nhân viên khác thu tiền. Nếu hai người này không phối hợp tốt, nhân viên thu tiền có thể tính tiền trước khi nhân viên quét xong tất cả sản phẩm. Kết quả? Một số món hàng chưa được tính vào hóa đơn, gây thất thoát doanh thu. Race condition trong lập trình cũng tương tự: Nếu hai goroutine không phối hợp chặt chẽ, dữ liệu có thể bị mất hoặc cập nhật sai. Nhiều ngôn ngữ lập trình giải quyết vấn đề này bằng mutex (khóa) để bảo vệ dữ liệu, nhưng cách này phức tạp và dễ gây lỗi nếu bạn quên khóa hoặc mở không đúng lúc. Trong Golang, ngoài mutex, còn có một giải pháp thanh lịch hơn: channel – giúp goroutine giao tiếp với nhau an toàn và trực quan hơn.
Channel trong Golang là một cơ chế cho phép các goroutine gửi và nhận dữ liệu với nhau một cách đồng bộ. Để dễ hình dung, bạn nghĩ về một dây chuyền làm bánh trong tiệm: một người (goroutine) đặt bột vào băng chuyền, và người kia ở cuối dây chuyền lấy bột ra để nướng. Băng chuyền chính là channel – nó đảm bảo bột được chuyển đúng thứ tự, không ai chen ngang.
Channel có hai loại chính: unbuffered channel và buffered channel. Unbuffered channel dựa trên nguyên tắc là nếu chưa có ai sẵn sàng nhận, goroutine gửi sẽ bị block; nếu không có ai gửi, goroutine nhận cũng phải đợi. Giống như bạn đưa thẳng cái bánh cho mình, gửi và nhận phải xảy ra cùng lúc. Nếu mình chưa sẵn sàng, bạn phải đứng đợi. Còn buffered channel thì Goroutine gửi và nhận không cần đợi nhau (miễn là bộ đệm chưa đầy hoặc chưa rỗng). Như một băng chuyền có kho chứa, bạn có thể đặt bánh vào mà không cần lấy ngay, miễn là kho chưa đầy. Unbuffered channel chặt chẽ, phù hợp khi cần đồng bộ tuyệt đối, còn buffered channel linh hoạt hơn cho các tác vụ không cần xử lý tức thời.
Dù mạnh mẽ, channel cũng có cái giá. Nếu bạn tạo quá nhiều channel không cần thiết, Garbage Collector (GC) của Go sẽ phải làm việc vất vả hơn. Mỗi channel là một đối tượng cần quản lý, và khi có hàng nghìn channel tồn tại, GC mất thêm thời gian dọn dẹp, gây ra chút overhead về hiệu suất. Không phải Go đột nhiên chậm hẳn, nhưng trong những hệ thống nhạy cảm với độ trễ – như giao dịch tài chính – sự đánh đổi này có thể đáng chú ý. Vậy nên, hãy dùng channel thông minh, đủ để giải quyết vấn đề, thay vì lạm dụng.
V. Goroutine mạnh thật, nhưng cũng có điểm yếu
Goroutine có mấy điểm mạnh đáng kể như bạn có thể tạo hàng triệu goroutine mà không lo máy "ngất", mỗi cái chỉ tốn khoảng 2KB bộ nhớ ban đầu thôi. Goroutine Scheduler quản lý OS thread thông minh, lại tích hợp sẵn trong runtime của Go, nên dùng cực kỳ tiện. Nhưng mà, nó không hoàn hảo. Nếu bạn quản lý channel không khéo, có thể gặp deadlock, hai goroutine cứ đợi nhau mãi không xong, hoặc resource leak – tài nguyên bị chiếm dụng mà không được giải phóng. Hiểu và dùng đúng mới là chìa khóa.
Concurrency trong Golang được thiết kế để tối ưu cho CPU, lý tưởng cho server web, chat app, hay công cụ như Docker, Kubernetes. Nhưng golang không được thiết kế để tận dụng GPU (Graphics Processing Unit), bộ xử lý chuyên dụng cho các tác vụ tính toán song song. Trong khi GPU có thể xử lý hàng nghìn luồng tính toán nhỏ cùng lúc như đồ họa, render video 3D hay machine learning, Golang lại chọn cách "bỏ qua" sức mạnh này để tập trung vào CPU. So với Python với TensorFlow có thể khai thác GPU để tính toán nhanh hơn nhiều, ví dụ huấn luyện mạng nơ-ron trên GPU có thể nhanh gấp 10-100 lần so với CPU. Thế mới thấy, chẳng có giải pháp nào hoàn hảo cho mọi trường hợp, mỗi công cụ đều có sân chơi riêng của nó.
VI. 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