Go Concurrency dành cho Java Developers.

Nam Vu
8 min readMay 28, 2020

Golang là một ngôn ngữ mới và đang tăng trưởng rất nhanh, rất nhiều các hệ thống lớn trên thế giới sử dụng Go là ngôn ngữ chính trong phần Backend services. Không thể nằm ngoài xu thế đó, Tôi! một người đã 10 năm nay code Java thuê để kiếm sống cũng phải "đú trend" với hy vọng rằng lịch sử sẽ không buông rơi … mình 🤣 , và bài viết này tôi sẽ chỉ đi vào phần "hay ho" nhất của Go là Concurrency và so sánh với Java, để giúp những Java Dev như tôi không muốn lịch sử buông rơi có thể dễ dàng tiếp cận hơn.

Goroutines vs Java Thread

Trước khi bắt đầu ta hãy đi tìm hiểu trước về các khái niệm như xử lý song song (parallelism) và xử lý đồng thời (concurrency). Xử lý song song không phải là xử lý đồng thời (parallelism is not concurrency). Parallelism là xử lý nhiều tác vụ cùng một lúc trong một thời điểm. Và Concurrency là trong một thời điểm xử lý nhiều tác vụ bằng cách chạy và hoàn thành trong khoảng thời gian chồng chéo, nó không nhất thiết là tất cả đều được chạy ngay lập tức.

Để có thể chạy được concurrency thì hệ thống phải có một cơ chế gọi là context swich:

Việc điều phối các process sẽ bao gồm, việc ngừng process hiện tại lại, lưu lại trạng thái của process này, lựa chọn process tiếp theo sẽ được chạy, load trạng thái của process tiếp theo đó lên, rồi chạy tiếp process tiếp theo. Quá trình này được gọi là Context Switch. Context Switch đôi khi phải phụ thuộc vào phần cứng (ví dụ việc lưu trữ lại trạng thái các thanh ghi mà process đang thực hiện). Context Switch là công việc tốn thời gian (trong bản thân kernel).

Sự khác nhau giữa Concurrent và Parallel

Số lượng các tác vụ có thể chạy Parallel được quyết định bởi số lượng core CPU mà hệ thống có, ví dụ máy tính của bạn có 8 core CPU thì trên thực tế chỉ có 8 process thực sự có thể chạy song song.

Go hay Java đều có cơ chế để vừa sử dụng concurrent lẫn parallel.

Java Thread

Với Java ta sử dụng OS Thread bằng cách sử class Thread ví dụ.

Thread trong Java sử dụng cơ chế Preemptive scheduling CPU có nghĩa là các Java Thread sẽ nhường quyền điều khiển cho CPU, tức là lúc này CPU sẽ điều khiển các context switching giữa các Thread. Và việc context switching giữa các Thread là khá cao vì mỗi thread chứa rất nhiều state, và đặc biệt ta phải tốn 4Mb bộ nhớ để tạo ra các Thread, nếu hệ thống tạo ra quá nhiều Thread thì OOM (out of memory) rất dễ xẩy ra (tuy nhiên có thể dụng Thread Pool để khắc phục điều này).

Tóm lại việc sử dụng OS Thread với context swiching là tốn thời gian và bộ nhớ.

Goroutine

Để lợi dụng được cơ chế vừa concurrency và parallelism, GO không sử dụng OS Thread giống Java mà đã tạo ra một thứ rất mới mẻ đó là Goroutine.

Bạn có thể hiểu nôm na là Goroutine là một mini Thread được chạy trên 1 Thread được quản lý bởi Go runtime và sử dụng cơ chế Cooperative scheduling CPU, có nghĩa Go runtime sẽ điều phối CPU bằng Go scheduler để thực hiện context switching các Goroutine với nhau hoặc Goroutine với các Thread chứa nó. Do đó 1 Thread có thể chứa rất nhiều Goroutine và các Goroutine chạy concurrency trên các Thread. Và đặc biệt rằng mỗi Goroutine không chỉ hoạt động trên duy nhất 1 Thread mà nó có thể "ném đi ném lại" (swiching) giữa các Thread khác nhau để tránh blocking lẫn nhau khi system call hay IO block, chính vì thế Goroutine vừa là concurrency vừa là parallelism.

Bạn đọc có thể tìm hiểu thêm về cách hoạt động của Go scheduler ở bài viết này nhé

Cách tạo Goroutine ta chỉ cần thêm keyword go trước mỗi func muốn chạy concurrency:

Một số đặc điểm của Goroutine là

  • Chi phí tạo ra các Goroutine rất rẻ, mỗi Goroutine chỉ tốn 4kb bộ nhớ khi khởi tạo, và có thể tăng lên tùy ý trong lúc runtime (dynamic allocation)
  • Việc khởi tạo và hủy một Goroutine được thực hiện ở Go runtime cho nên việc này chi phí của nó cũng rất rẻ và nhanh.
  • Do việc Go runtime sử dụng cơ chế cooperative scheduling cho nên việc context swiching giữa các Goroutine cũng rất rẻ.

Vì vậy mỗi ứng dụng có thể chạy cả chục nghìn Goroutine trong một thời điểm mà vẫn "mượt mà" , để làm được điều này thì Go runtime phải xử lý vô cùng phức tạp và to tay với Go scheduler và Go netpoller.

Rõ ràng là so với Java Threads rõ ràng là Goroutines ăn đứt đuôi con nòng nọc rồi còn gì.

Và cuối cùng có một điều đặc biệt nữa nhờ cơ chế cooperative scheduling của Go runtime đó là với Go ta có thể ép chương trình chạy trên nhiều CPU core bằng cách sử dụng hàm runtime.GOMAXPROCS(2) , số 2 ở đây là Go runtime sẽ sử dụng 2 CPU core để chạy ứng dụng.

Ví dụ:

Mutex Lock

Trong môi trường đa luồng làm việc với share data để đảm bảo việc dữ liệu được xử lý chính xác và đúng thứ tự tránh những vấn đề rắc rối như race condition, deadlock, resource starvation ... ta phải có cơ chế locking và wait hợp lý.

Mutex Lock với Java

Với Java ta có rất nhiều cách làm như sử dụng keyword synchronized, ReentrantLock hay Semaphore hoặc đơn giản sử dụng Java Monitor Object với lock()unlock(). Ví dụ ta có một hàm thread-safe tăng một biến count trong Java ta có những cách dùng sau:

Sử dụng synchronized keyword

Hoặc

Sử dụng Reentrantlock

Sử dụng binary Semaphore

Túm cái váy lại với Java ta có 1 tỷ 9 vạn cách để locking.

Mutex Lock với Golang

Golang cũng có những phương thức để đồng bộ hóa các goroutine bằng cách sử Mutex trong gói sync ví dụ:

Wait với Java

Trong Java để đợi một Thread kết thúc ta thường sử dụng CountDownLatch ví dụ ta tạo ra 4 thread và đợi khi kết thúc cả 4, chương trình sẽ in ra kết quả.

Wait với Golang

Với Golang để đợi tất cả các Goroutine hoàn tất ta sử dụng WaitGroup trong gói sync . Ta sử dụng method Add() để biểu thị số lượng Goroutine cần phải đợi và method Done() để báo hiệu rằng một Goroutine đã hoàn thành và Wait() để đợi tất cả Goroutine phải được hoàn thành. Nó cũng gần giống với CountDownLatch trong Java. Ví dụ

Lock-free

Cả Golang và Java đều có thể giải quyết bài toán Mutual Exclusion bằng cơ chế CAS (compare-and-swap) thay thế cho Lock, hay còn gọi là Lock-Free bằng cách sử dụng các toán tử Atomic mà hardware hỗ trợ.

Atomic với Java

Trong Java để sử dụng Atomic ta sử dụng các class dạng Atomic* như AtomicInteger để hỗ trợ các toán tử automic kiểu Int, ví dụ có hai thread đồng thời tăng giá trị biến counter.

Atomic với Golang

Tương tự với Java thì Golang cũng hỗ trợ các toán tử atomic bằng cách sử dụng gói sync/atomic, ví dụ

Khác với Java thì Golang sử dụng con trỏ để đưa biến và giá trị tăng vào gói atomic có sẵn.

Share data giữa các luồng.

Ví dụ điển hình của việc share data giữa các luồng (thread/goroutine) là vấn đề về Producer–Consumer mà hay được gọi là sharing memory by communicating.

Share data với Java

Với Java để giải quyết vấn đề này ta thường sử dụng Object Monitor với các phương thức như lock(), unlock() để lock hoặc unlock share data và sử dụng wait()signal() để liên lạc (communitcate) giữa các thread với nhau. Ví dụ tôi thiết kế một ThreadSafeQueue với hai hàm produce() và consume() để lấy (consume) hoặc đẩy (produce) dữ liệu và một LinkedList với giới hạn size là 10.

Share data với Golang

Với Golang ta có một thứ mới mẻ hơn đó là Channel để các Goroutine giao tiếp và trao đổi dữ liệu với nhau mà không cần phải dùng biến toàn cục từ bên ngoài như Java. Nôm na có thể hiểu channel là các đường ống nối giữa các Goroutine.

Để tạo Go Channel ta sử dụng method make(chan [type]) ví dụ tạo một channel kiểu int: channel := make(chan int64) . Ví dụ ta viết một trương trình lấy dữ liệu từ một Goroutine bằng channel như sau:

Kết quả màn hình sẽ hiển thị say: Hello!

Hoặc tạo một group cách channel bằng cách thêm tham số vào hàm make ví dụ dataChannel := make(chan string, 3)

Kết quả màn hình sẽ hiển thị
say: Hello!
say: Hola!
say: Ciao!

Bản chất của channel rất dễ dàng để sử dụng để giải quyết vấn đề về Producer–Consumer bởi vì các Goroutine có thể trao đổi dữ liệu dùng chung (share data) qua channel. Ví dụ:

Ta có thể thấy hai hàm produceconsume có thể trao đổi dữ liệu qua lại bằng channel, do đó việc gửi nhận dữ liệu vô cùng dễ dàng và an toàn, khỏi phải dùng lock, wait hay notify trong Java, khỏe re luôn 😙 .

Channel thích hợp để trao đổi dữ liệu giữa các Goroutine vậy trong trường hợp các Goroutine gọi lẫn nhau, ví dụ ta có hai Goroutine A và B, trong A có gọi tới B, khi goroutine A đột ngột bị shutdown thì cũng phải cơ chế notify goroutine B shutdown theo. Trong trường hợp này ta có thể dùng Context để notify giữa Goroutine ví dụ.

Ta có thể thấy Context trả về một function là cancel, nếu cancel() được gọi thì context sẽ gửi tín hiệu tới channel Done lúc đó các Goroutine khác connect với nhau qua context sẽ nhận được tín hiệu và thực hiện shutdown.

Với Golang thì lập trình đa luồng dễ dàng và hiệu quả hơn, ngay cả cú pháp cũng ngắn gọn và dễ hiểu hơn hẳn Java. Với ứng dụng đòi hỏi phải sử dụng một số lượng hơn các concurrency như các dịch vụ Web/Mobile Backend hay các dịch vụ Microservice thì Golang rõ ràng là một sự lựa chọn đáng đồng tiền bát gạo hơn Java.

--

--