[Java] Multi-threading - Nhiều luồng liệu có thực sự khiến chương trình của chúng ta trở nên nhanh hơn ?
Published: 2024-07-11 02:17:37

1.Đặt vấn đề
Khi nói về việc tăng hiệu năng tổng thể của một chương trình Java, chắc hẳn một số anh chị em Java developer sẽ nghĩ ngay đến multi-threading(tận dụng việc CPU đa nhân để xử lý song song các công việc cùng một lúc) với tư duy là: “Nhiều người cùng làm một việc thì bao giờ chả nhanh hơn một người làm”. Tuy nhiên, việc lạm dụng sử dụng multi-threading mà chưa thực sự hiểu cách hoạt động và tương tác giữa các luồng và các core của CPU có thể dẫn đến giảm hiệu năng của chương trình, thậm trí có thể dẫn đến việc chương trình chạy bị thiếu chính xác. Một trong những nguyên nhân phổ biến dẫn đến chương trình bị chạy sai có thể kể đến là race-condition, nhiều developer nghĩ chỉ cần sử dụng các từ khóa synchronized hay lock để giải quyết vấn đề race-condition và hiệu năng chương trình vẫn cao. Tuy nhiên liệu có phải thực sự như vậy. Trong bài viết này, chúng ta sẽ đi làm rõ điều đó. 



Việc tận dụng tối đa sức mạnh của CPU đa nhân đa luồng khi phát triển phần mềm không phải là việc đơn giản, chính vì vậy việc hiểu cách hoạt động, tương tác và trao đổi dữ liệu giữa các luồng với nhau thông qua phần cứng là vô cùng quan trọng. Điều này còn được gọi với một key concept đó là mechanical sympathy. Khái niệm này lần đầu được một racer đưa ra như sau: “You don’t have to be an engineer to be be a racing driver, but you do have to have Mechanical Sympathy”, có thể dịch nôm na là khi đua xe thì việc cảm nhận xe, thậm chí là cảm nhận đến từng hơi thở của xe là điều vô cùng quan trọng. Sau này khái niệm này được Martin Thompson(Một chuyên gia về phát triển ứng dụng hiệu năng cao và độ trễ thấp) đưa vào việc phát triển phần mềm để mô tả về tầm quan trọng của việc hiểu phần cứng hoạt động như thế nào để khi lập trình phần mềm có thể tận dụng tối đa sức mạnh của phần cứng. 

==> CPU hoạt động như thế nào ? 

Khi đi mua CPU hay mua máy tính chúng ta vẫn thường quan tâm đến một số thông số phần cứng như RAM gì(DDR mấy), ổ cứng gì(HDD hay SSD)… tuy nhiên, có một thông số cũng rất nhiều anh em quan tâm đó là cache của CPU có lớn không. Tại sao chúng ta lại phải quan tâm đến dung lượng cache của CPU vậy?. Đúng vậy, đây là vùng nhớ gần nhất mà trung tâm xử lý của CPU lấy dữ liệu ra để tính toán, cho nên việc dung lượng cache nhiều cũng có thể làm cho chiếc PC hay laptop của chúng ta trở nên nhanh hơn.  

Bây giờ hãy xem qua 1 core của CPU có gì nhé: 



Như chúng ta thấy ở hình trên xuất hiện khá nhiều từ khóa Cache. Đúng vậy, đây chính là CPU Cache đã được đề cập ở trên khi mua PC hay laptop. CPU cache là bộ nhớ được dùng bởi bộ xử lý trung tâm của máy tính nhằm giảm thời gian truy cập dữ liệu trung bình từ bộ nhớ chính (DRAM). CPU Cache là một bộ nhớ nhỏ hơn, nhanh hơn RAM và lưu trữ bản sao của những dữ liệu thường xuyên được truy cập trên bộ nhớ chính. Hầu hết CPU đều có các bộ nhớ cache độc lập khác nhau, bao gồm cache chỉ dẫn và cache dữ liệu, nơi mà dữ liệu được xếp thành nhiều lớp khác nhau. 

Mỗi CPU core đều có bộ nhớ cache riêng đó là bộ nhớ L1, L2 và bộ nhớ chung (share memory) L3 cho tất cả các lõi (CPU core) và CPU sẽ cache data lên đó. Vì vậy thay vì chương tình sẽ truy vấn và DRAM thì chương trình sẽ truy vấn vào bộ nhớ riêng L1, L2 trước nếu không thấy thông tin hợp lệ thì mới truy cập vào share memory L3 hoặc main memory (DRAM). Khi mà bộ xử lý cần phải đọc hay viết vào một vị trí trong bộ nhớ chính, nó sẽ tìm trong bộ nhớ cache đầu tiên. Nhờ vậy, bộ xử lý đọc hay viết dữ liệu vào cache ngay lập tức nên sẽ nhanh hơn nhiều so với đọc hay viết vào bộ nhớ chính. 

Khi chương trình muốn truy cập vào bộ nhớ đầu tiên sẽ tìm trên internal caches L1 nếu không có thì cache miss sẽ xảy ra và chương trình sẽ tiếp tục tìm kiếm trên internal cache L2 và tiếp tục tìm kiếm ở share cache L3 và cuối cùng là tìm ở bộ nhớ chính DRAM, và so với việc lấy được dữ liệu từ L1 thì việc lấy dữ liệu ở DRAM sẽ lâu hơn gần 60 lần (60 nano second so với 1,2 nano second). 

Ở trên là luồng lấy dữ liệu của trung tâm xử lý các core, vậy dữ liệu được giao tiếp giữa các cache này và RAM có dạng như thế nào? Đó chính là CacheLine. 

CacheLine là dữ liệu được chuyển giữa bộ nhớ chính DRAM và CPU cache theo từng khối cố định kích cỡ. Có thể hiểu đơn giản là CacheLine là một bộ nhớ nhỏ làm trung gian giữa CPU Cache và Main Memory, giúp cho việc đọc hoặc ghi dữ liệu từ CPU Cache tới Main Memory. 





Khi một cache line được sao chép từ bộ nhớ chính vào cache thì một cache entry được tạo ra. Nó sẽ bao gồm cả dữ liệu được sao chép và vị trí của dữ liệu yêu cầu (gọi là 1 tag). 

Khi bộ xử lý cần đọc hoặc viết một vị trí trong bộ nhớ chính, nó sẽ tìm entry tương ứng trong cache đầu tiên. Cache sẽ kiểm tra nội dung của vị trí dữ liệu yêu cầu trong bất cứ cache line nào có thể có địa chỉ. Nếu bộ xử lý tìm thấy vị trí dữ liệu trong cache, một cache hit sẽ xảy ra. Tuy nhiên, nếu bộ xử lý không tìm thấy được vị trí dữ liệu trong cache, thì một cache miss sẽ xảy ra. Trong trường hợp cache hit, bộ xử lý đọc hoặc viết dữ liệu vào cache line ngay lập tức. Còn nếu là cache miss, cache sẽ tạo một entry mới và sao chép dữ liệu từ bộ nhớ chính, sau đó yêu cầu được đáp ứng từ nội dung của cache. 

Cache line được quản lý với một bảng băm (hash-map) với mỗi địa chỉ lưu trong hash-map sẽ được chỉ định tới một cache line. 

2. Các vấn đề thường gặp khi sử dụng multi-threading
2.1 False sharing
False sharing là hiện tượng mà khi các thread trên các core khác nhau muốn thay đổi dữ liệu trên 1 cache line dẫn đến việc hiệu năng của chương trình sẽ bị giảm đáng kể. Để dễ tưởng tượng ta cùng đi vào ví dụ sau: 

Ta có hai biến X và Y được truy cập với 2 thread từ 2 CPU core khác nhau, một thread thay đổi X và sau vài ns (nano second) thread còn lại thay đổi Y. 



Nếu hai giá trị X, Y trên cùng một cache line (xác định bằng cách băm địa chỉ của X và Y và cả hai đều ra cùng 1 cache line) giả dụ là cache line của CPU core chứa thread 1, thì lúc đó thread 2 còn lại sẽ phải lấy một bản copy của Y từ cache line của thread 1 từ L2, L3 hoặc DRAM, trường hợp đó ta gọi là False Sharing, và chính điều này cũng ảnh hưởng đáng kể tới hiệu xuất của chương trình. 

2.2 Race condition 
Chắc hẳn nếu bạn đã lập trình multi-threading thì không còn xa lạ với khái niệm race-condition nữa rồi. Race condition xảy ra khi có từ 2 thread trở lên cùng truy cập vào một vùng nhớ chung (shared memory) với ít nhất 1 thread thực hiện việc thay đổi giá trị trên vùng nhớ đó và làm dữ liệu không đồng bộ giữa các luồng dẫn đến sai sót khi tính toán. 

Để dễ hình dung hơn, hãy bắt đầu với một ví dụ đơn giản là chúng ta cần tăng dần 1 biến đếm từ giá trị 0 đến 100*10^6. Chúng ta đều thấy con số 100M là khá lớn, vậy làm cách nào để tăng tốc độ đếm lên? Và theo tư duy thông thường thì ta sẽ nghĩ đến việc xử lý multi-thread tức là sử dụng nhiều hơn 1 thread để tính toán, ví dụ sau đây ta sử dụng 2 thread để xử lý việc tăng dần biến counter với hy vọng rằng tốc độ thực thi sẽ giảm một nửa ?

public void multiCounter() throws Exception {
 
// First thread
 
final Thread t1 =
 
new Thread(() -> {
 
for (int i = 0; i < 50_000_000; i++) {
 
sharedCounter++;
 
        }
 
      });
 
// Second thread
 
final Thread t2 =
 
new Thread(() -> {
 
for (int i = 0; i < 50_000_000; i++) {
 
sharedCounter++;
 
        }
 
      });
 
// Start threads
 
t1.start();
 
t2.start();
 
// Wait threads
 
t1.join();
 
t2.join();
 
System.out.println("counter=" + sharedCounter);
 
}
Cùng thử thực thi đoạn code trên và xem kết quả ra sao nhé. 

Tada… 

counter=55648901
counter=62176211
counter=52795666
Đúng vậy, kết quả là luôn khác 100M. Điều này xảy ra nguyên nhân chính là do race-condition. 

Hẳn là sẽ có anh em thắc mắc là bây giờ ai dùng cách tạo thread như trên nữa. Tuy nhiên, trong source code trên tôi sử dụng cách tạo thread truyền thống để giả lập cho dễ hình dung. Việc sử dụng threadpool trong java cũng chỉ giúp quản lý các thread và giúp tiết kiệm việc khởi tạo thread chứ không hề giúp chúng ta xử lý gì trong vấn đề race-condition này. Chính vì vậy khi  dùng executor của java để chạy lại ví dụ trên thì kết quả vẫn không có gì thay đổi. Biến counter vẫn không thể reach đến 100M. 

ExecutorService executorService = Executors.newFixedThreadPool(2);
 
        executorService.execute(new Runnable() {
 
            @Override
 
            public void run() {
 
                for (int i = 0; i<50_000_00;i++){
 
                    sharedCounter++;
 
                }
 
            }
 
        });
 
        executorService.execute(new Runnable() {
 
            @Override
 
            public void run() {
 
                for (int i = 0; i<50_000_00;i++){
 
                    sharedCounter++;
 
                }
 
            }
 
        });
 
        Thread.sleep(3000);
 
        System.out.println(sharedCounter);


Một ví dụ thực tế hơn ngoài đời cho vấn đề race-condition đó là bài toán rút tiền ở cây ATM. Giả sử có 1 thẻ ATM và 1 thẻ Visa Debit cùng link đến 1 tài khoản ngân hàng và đi rút tiền cùng lúc. Trong tài khoản còn 50k vừa đủ làm bát bún real cool và cốc trà đá. Mình đồng thời rút ở cả 2 máy ATM 50k. Nếu không xử lý race-condition, mình sẽ may mắn rút được tổng cộng 100k ở cả 2 máy. 

2.3 Happens-before Ordering trong java 
Trước khi tìm hiểu Happens-before relationship ta sẽ tìm hiểu về một khái niệm trước đó là  Instruction Reordering(IR). IR là cách mà các CPU hiện đại thực hiện sắp xếp lại thứ tự thực hiện các instruction để có thể thực thi chúng song song để tăng hiệu quả tính toán của CPU. 

Cùng xem ví dụ sau nhé: 



Sau khi CPU thực hiện Instruction Reordering lại thì thứ tự thực hiện các phép toán sẽ như sau: 



Với các instruction trên được sắp xếp lại, CPU có thể thực hiện 3 instruction đầu tiên song song vì chúng không phụ thuộc lẫn nhau trước khi thực thi instruction thứ 4 -> tăng performance. 

Tuy nhiên, trong vài trường hợp khi thực hiện thì Instruction Reordering sẽ dẫn đến việc chương trình thực hiện không đúng trên nhiều luồng như ví dụ sau đây: 





Nếu CPU sắp xếp lại thứ tự thực hiện instruction (2) trước (1) thì ở Thread2 có thể xảy ra trường hợp điều kiện (3) đúng nhưng giá trị balance chưa được update -> chương trình sẽ không hoạt động đúng, vẫn lấy ra giá trị balance cũ. Ở đây Happens-before relationship sẽ giải quyết vấn đề đó, nó đảm bảo thứ tự thực hiện được giữ nguyên. Tất cả thay đổi xảy ra ở Thread1 trước khi ghi isDepositSuccess sẽ được nhìn thấy và cập nhật ở Thread2 khi đọc isDepositSuccess. 

Đối với phần happen-before này, Chúng ta có thể sử dụng biến volatite, synchronized hay dùng các lớp Atomic để giải quyết. 

3. Giải quyết các vấn đề về concurrent programming 
3.1 Cơ chế loại trừ lẫn nhau(Mutual Exclusion- Mutex)
Trong ngành khoa học máy tính, tương tranh là một tính chất của các hệ thống bao gồm các tính toán được thực thi trùng nhau về mặt thời gian, trong đó các tính toán chạy đồng thời có thể chia sẻ các tài nguyên dùng chung. Hoặc theo lời của Edsger Dijkstra: “Tương tranh xảy ra khi nhiều hơn một luồng thực thi có thể chạy đồng thời.” Việc cùng sử dụng các tài nguyên dùng chung, chẳng hạn bộ nhớ hay file dữ liệu trên đĩa cứng, là nguồn gốc của nhiều khó khăn. Các tranh đoạt điều khiển (race condition) liên quan đến các tài nguyên dùng chung có thể dẫn đến ứng xử không đoán trước được của hệ thống. Việc sử dụng cơ chế loại trừ lẫn nhau(mutual exclusion) có thể ngăn chặn các tình huống chạy đua, nhưng có thể dẫn đến các vấn đề như tình trạng bế tắc (deadlock) và đói tài nguyên (resource starvation). 

Tóm lại Mutual Exclusion là ta phải đảm bảo trong một thời điểm chỉ duy nhất 1 thread được thực thi vào share memeory. 

Vậy để thực hiện mục tiêu trên thì cơ chế Loking là cơ chế gần như bắt buộc phải thực hiện, thường ta có hai cơ chế locking thường được sử dụng là Pessimistic Locking và Optimistic Locking. 

3.2 Locking trong Java
Thực tế sử dụng lock sẽ gây lãng phí tài nguyên thread của CPU vì khi cơ chế locking được thực thi các thread khác sẽ bị block cho tới khi thread đang thực thi được giải phóng. Ngoài ra khi sử dụng lock không cẩn thận sẽ nảy sinh ra rất nhiều vấn đề như dead lock. 

3.2.1 Sử dụng ReentrantLock
Cơ chế chung là lock và unlock. Một thread có thể lock nhiều lần và lưu ý phải unlock số lần bằng số lần lock để chương trình thực hiện đúng (vậy nên nó có tên gọi reentrant) 

Chúng ta có thể giải quyết bài toán counter ở trên sử dụng reentrantLock như sau: 



public class MutualExclusion {
 
  
 
    private static int COUNTER = 0;
 
    private static Lock LOCK = new ReentrantLock();
 
  
 
    public static void main(String... args) throws Exception {
 
          
 
        final Runnable increaseCounterFunc = () -> IntStream
 
                .range(0, 50_000_000)
 
                .forEach(Application::increaseCounter);
 
  
 
        final var first = new Thread(increaseCounterFunc);
 
        final var second = new Thread(increaseCounterFunc);
 
        first.start();
 
        second.start();
 
        first.join();
 
        second.join();
 
          
 
        System.out.println(COUNTER);
 
    }
 
      
 
    private static void increaseCounter(int i) {
 
        lock.lock();
 
        lock.lock();
 
        ++COUNTER;
 
        lock.unlock();
 
        lock.unlock();
 
    }
 
}


Chạy đoạn code trên, kết quả sẽ luôn luôn là 100M. Critical region đã được bảo vệ bởi khóa. Sẽ khóa trước khi thay đổi giá trị của COUNTER và mở khóa khi thực hiện xong. Do đó luôn luôn chỉ có 1 thread được truy cập để thay đổi giá trị. Race condition được giải quyết. 

3.2.2 Insintric lock
Trong Java, ngoài việc sử dụng ReentrantLock để đảm bảo race condition. Thì sử dụng insintric lock cũng phổ biến không kém. Đó chính là việc sử dụng từ khóa synchronized. Cụ thể hơn, insintric lock có các loại: Synchronized method, Synchronized static method, Synchronized statement. 

Với synchronized method, chỉ cần thêm từ khóa synchronized vào method là được. Khi đó, intrinsic lock xảy ra trên chính đối tượng gọi hàm. Nếu là static method thì intrinsic lock trên class đó. Cụ thể đoạn code cho bài toán tăng biến như sau: 



private static synchronized void increaseCounter(int i) {
 
    ++COUNTER;
 
}
Với synchronized statement, ta sẽ đưa phần critical region vào synchronized block và phải khai báo đối tượng lock để thực hiện intrinsic lock. Sửa đoạn code trên như sau: 

private static void increaseCounter(int i) {
 
    synchronized(MutualExclusion.class) {
 
        ++COUNTER;
 
    }
 
}
Lưu ý với synchronized statement, nếu khai báo đối tượng lock không cẩn thận rất sẽ có bug phát sinh. Phải đảm bảo tất cả các thread đều được lock trên một đối tượng duy nhất không đổi trong suốt quá trình. Ví dụ, nếu khai báo như sau, kết quả sẽ không đúng: 

private static void increaseCounter(int i) {
 
    synchronized(new Object()) {
 
        ++COUNTER;
 
    }
 
}
Vì với mỗi lần cố gắng truy cập critical section, ta sẽ có các đối tượng khác nhau. Giống như việc 1 ổ khóa nhưng rất nhiều chìa có thể mở được, vậy khóa đó không an toàn. 

3.2 CAS(Lock-Free)
Việc sử dụng các loại lock như phần trên đã đề cập hoàn toàn có thể đảm bảo được các việc các logic được tính toán đúng theo mong muốn của chúng ta. Tuy nhiên, việc sử dụng locking như vậy sẽ dẫn đến việc context-switching giữa các thread với nhau và làm cho chương trình của chúng ta bị giảm hiệu năng. 

Giống như việc giao ca giữa các nhân viên làm ca ở các quán café vậy. Nhân viên ca trước phải tổng kết doanh thu của ca đó, sau đó bàn giao lại cho nhân viên ca mới. Context-switching là việc chuyển giao dữ liệu giữa các luồng hoạt động với nhau. Các luồng đang xử lý dữ liệu đó cần lưu lại data và trạng thái của thread đó trước khi switching, sau đó các luồng khác khi đảm nhận thì lại cần phục hồi data và trạng thái đã được lưu trữ bởi luồng trước đó. Việc này gây ra một chi phí không hề nhỏ làm cho hiệu năng của chương trình bị giảm đang kể. 

Để giải quyết việc context-switching, chúng ta có thể sử dụng cơ chế CAS(Compare and swap). CAS là một kĩ thuật được sử dụng để thiết kế các thuật toán xử lý đồng thời lợi dụng cơ chế hoạt động của các core CPU. Khi nhiều thread cùng thực hiện CAS trên một biến, chỉ có duy nhất 1 thread truy cập thành công và thay đổi giá trị. Các thread còn lại không bị block, chúng vẫn thực hiện CAS nhưng không có gì thay đổi vì giá trị mới đã được thay đổi bởi luồng khác. 

Trong Java, cơ chế CAS được thể hiện qua các lớp Atomic. Chúng ta có thể giải quyết bài toán counter trên sử dụng Atomic class như sau: 



private static AtomicInteger COUNTER = new AtomicInteger(0);
 
private static void increaseCounter(int i) {
 
    COUNTER.incrementAndGet();
 
}
Như vậy, việc sử dụng CAS đã hoàn toàn xử lý được context-switching, nhưng nếu đã có CAS rồi thì các phần lock sẽ không cần đến nữa à? Đúng vậy bản thân CAS cũng lại có những nhược điểm. 

CAS thao tác trực tiếp với memory, thực hiện phép so sánh và thay đổi giá trị. Nhờ đó các thread không bị blocked. Tuy nhiên đó cũng là mặt hạn chế, chương trình trở nên phức tạp hơn trong trường hợp CAS fail, cần thực hiện retry cho đến khi thành công. 

Ngoài ra, vì nó cần so sánh kết quả mới với vùng nhớ hiện tại, nếu vùng nhớ càng lớn, việc compare càng mất nhiều thời gian. Do đó cần lưu ý khi sử dụng các Atomic variable đặc biệt là AtomicReference. 

Do đó, tùy từng bài toán và yêu cầu cụ thể để quyết định sử dụng cơ chế nào. 

Bonus: Ứng cử viên nặng kí cho lập trình đa luồng 


Tiện đang bàn về vấn đề đa luồng trong lập trình Java thì có một ngôn ngữ lập trình cũng nổi lên với các ưu điểm như “nhẹ”, “nhanh”, “gọn” trong những năm gần đây, đặc biệt là về khả năng xử lý đồng thời trong chương trình. Chắc các bạn cũng đã đoán được ngôn ngữ lập trình mà tôi đề cập đến ở đây. Đó chính là Golang. 

Liệu Golang có thực sự mạnh mẽ trong việc xử lý đồng thời như vậy? Trong phần này tôi  xin tập trung bàn đến phần xử lý đồng thời trong Golang với Goroutines và cùng xem Goroutines liệu có gì khác so với Thread trong Java nhé. 

Goroutines là gì? Goroutines là những luồng gọn nhẹ trong Go, được khởi tạo với chỉ từ 2KB trong stack size có thể tăng hoặc giảm vùng nhớ tùy yêu cầu sử dụng.  





Chúng ta hay cùng điểm qua một số khía cạnh giữa Goroutines và Thread trong Java nhé. 

Bộ lập lịch(Scheduler)
- Java: Sử dụng native thread trong OS. Mỗi thread trong java ứng với một thread trong nhân hệ điều hành. Java không quyết định việc thread nào được chạy trong core của CPU mà việc đó hoàn toàn là do bộ lập lịch của hệ điều hành. 

- Goroutines: Khác với Java, Golang không sử dụng native thread trong OS mà Go sử dụng Goroutines được sắp xếp để chạy trong các Thread. Các Goroutines sẽ không chạy theo bộ lập lịch của hệ điều hành mà chúng được chạy theo bộ lập lịch riêng gọi là Go runtime. Có thể có hàng ngàn Goroutines chạy trong 1 thread của OS. Nếu thread này bị block, 1 thread mới được tạo ra. Một vài goroutines sẽ ở lại thead cũ để xử lý tiếp, số goroutines còn lại dược chuyển qua thread mới để process tiếp. 







Chính vì các Goroutines được quản lý và lên lịch chạy bởi Go runtine nên chúng được xử lý để có thể giảm thiểu chi phí tối đa về cho việc switching giữa các Goroutines với nhau.  

Cụ thể hơn, đối với Java, khi một một thread bị block thì một thread khác sẽ được lập lịch để chạy thay thế. Trong thời gian switch 2 thread thì bộ lập lịch cần lưu trữ lại trạng thái của thread đó, cụ thể ở đây là tất cả các thanh ghi(registers) như PC (Program Counter), SP (Stack Pointer), segment registers, 16 XMM registers, FP coprocessor state, 16 AVX registers…. Điều này khiến cho chi phí switching thực sự là đáng kể. 

Trong khi đó Golang thì khi switching giữa các Goroutines thì chỉ cần lưu trữ trạng thái của 3 thang ghi là Program Counter, Stack Pointer and DX. 

Dung lượng
- Java: Các thread có thể được cấu hình dung lượng khi chạy chương trình bảng option –Xss. Tuy nhiên, khi không cấu hình option trên thì tùy thuộc vào VM có dạng 32bit hay 64bit mà dung lượng mặc định của Thread trong Java là 512Kb hay 1024Kb. Và đặc biệt là dung lượng của thread không thể resize khi chúng ta đã chạy chương trình. 

- Go: Như đã đề cập trong khái niệm ở trên. Dung lượng của mỗi goroutines khi khởi tạo chỉ từ 2Kb và có thể tùy chỉnh tăng giảm vùng nhớ khi sử dụng. 

- Như vậy chúng ta đã có thể thấy được việc dung lượng 1 thread trong java có thể lớn gấp từ 250 lần so với dung lượng của một goroutines. 



Như vậy, với 2 tiêu chí đề cập ở trên, chúng ta đã có thể thấy được sự khác nhau cơ bản giữa “Thread” trong Golang và Thread trong Java. Tuy nhiên, để có thể hiểu sâu hơn về cách sử dụng và giao tiếp giữa các Goroutines thì có lẽ tôi sẽ dành riêng phần đó sang một bài viết khác. 

4. Kết luận 
Tóm lại, khi giải quyết một bài toán về hiệu năng không phải cứ sử dụng multi-thread là có thể tăng hiệu năng của chương trình, đôi khi một thread vẫn có thể chạy nhanh hơn nhiều thread.  
Chính vì vậy, khi lập trình concurrency chúng ta cần cố gắng thiết kế các luồng đọc các dữ liệu khác nhau, cần kết hợp linh hoạt giữa đa luồng và đơn luồng, hạn chế sử dụng lock vì sẽ gây context switching ảnh hưởng đến hiệu năng của hệ thống . Làm được điều đó cũng chính là việc bạn đã trở thành một lập trình viên “Mechanical Sympathy”- nghe thấy tiếng thở của CPU từ đó các chương trình bạn tạo ra sẽ luôn tận dụng tối đa được sức mạnh của phần cứng và hiệu năng chương trình sẽ luôn được tối ưu. 
Golang là 1 ngôn ngữ lập trình server-side mạnh mẽ về khả năng xử lý đồng thời, đặc biệt là với sự phát triển của microservices thì việc chia business  ra các service nhỏ độc lập lại khiến cho việc tận dụng tối đa các điểm mạnh của các ngôn ngữ lập trình khác nhau. Hiện tại *** cũng đã có các team triển khai các services được viết bẳng Golang và cũng có khá nhiều bài nghiên cứu về Golang trên CoP nên nếu anh em muốn học thêm một ngôn ngữ back-end mới thì Golang cũng không phải là một lựa chọn tồi.