kiến thức [Event Tặng Title] Concurrency/multi-threading in the nut shell

sirhung1993

Người qua đường
Chào mọi người, trong thread này mình tập trung làm rõ về các khái niệm hay được dùng để chỉ về việc xử lý song song như multi-threading, concurrency, parallelism hoặc asynchronous / non-blocking IO.

Mình hiện tại thì làm Data Engineer, công việc thường ngày hay phải xử lý những batch data lớn, nên có cơ hội được vọc vạch nhiều vào thư java.util.concurrent.* . Kiến thức trong bài sẽ được lấy từ cách mình triển khai trong Java ( có kết hợp so sánh với Go-lang vs C++) và 1 phần trích dẫn từ quyển Advanced Programming in the UNIX Environment, 3rd Edition.

Về phần Async/non-blocking IO thì sẽ lấy ví dụ từ NodeJS vs Java Quarkus. ( tại sao phần này lại liên quan đến xử lý song song thì sẽ giải thích sau)

Các topic chính:

1. Hardware

Đây là lầm tưởng nhiều người mắc phải nhất khi nói về multi-threading/concurrency programming .
Không nhất thiết hardware phải nhiều core và nhiều thread (đôi khi là memory) để support việc lập trình multi-threading/concurrency . Tuy nhiên, hardware có nhiều core/thread/memory thì sẽ ảnh hưởng tới performance, nhưng không quá ảnh hưởng đến cách software được phát triển.
Phần processes/threads đã được trừu tượng hóa ở OS , nên code không phải quan tâm nhiều đến việc hardware có support multi-thread hay không.

Để hiểu hơn khái niệm concurrency vs multi-threading, mình giới thiệu 1 tí về cách hardware chạy machine code.


1200px-MIPS_Architecture_%28Pipelined%29.svg.png

(Nguồn wiki _ MIPS architecture)

Lấy kiến trúc MIPS làm ví dụ cho đơn giản, thì 1 core có 2 bộ phận chính là Program Counter(PC)Arithmetic Logic Unit (ALU) .
Và pipe-line có 5 stage ( IF/ID/EX/MEM/WB) .

Giả sử ta chỉ có 1 core như hình, thì việc multi-threading/parallel là không support ở hardware. Nhưng concurrency thì có thể.
Tức là với pipe-line có 5 stages, thì đôi khi 1 số stage bị block bởi việc access-memory, thì 1 core vẫn chạy ở những stages khác.
Về lý thuyết nó có thể chạy 1 lúc 5 tasks.

Số stages của pipe-line ảnh hưởng rất lớn tới performance của CPU, nhưng hạn chế của nó là làm tăng độ phức tạp của tập lệnh (ISA - instruction set architecture). Lấy ví dụ như x86 là 1 tập lệnh CISC (complex Instruction Set Computer) , đối lập với MIPSARM thuộc RISC (Reduced ISC). CPU x86 của Intel vs AMD có tới vài chục stages hoặc hơn.

Số stages càng nhiều, đại diện cho khả năng concurrency của hardware . Số core/thread càng nhiều, đại diện cho khả năng multi-thread/parallelism .
Nghĩa là kể cả chỉ có 1 core/thread, thì mình vẫn chạy được code 1 cách concurrency .
Chưa kể những bộ phận bí mật của Intel/AMD như branch-prediction / fetch-prediction / direct memory access còn làm tăng khả năng concurrency lên rất nhiều.

Tới đây thì các dev cũng đừng quá lo vì quá nhiều keyword, vì OS đã abstract hầu hết đặc tính của hardware. Việc của dev chỉ là dùng thư viện support concurrency/multi-thread.
 
Last edited:
2. OS ảnh hưởng như thế nào đến multi-threading?

Như đã nói ở trên , OS đã trừu tượng hóa hết đặc tính của hardware. Và Linus Torvalds có 1 quote rất nổi tiếng:

Everything is a file descriptor

Mở rộng ra 1 tí thì, câu trên thiếu đoạn sau "trừ thread".

Nếu máy đang dùng để đọc VOZ là Linux-based hoặc UNIX thì có thể thử câu lệnh sau:

$ ls -lsh /proc

Output sẽ là các directory có đánh số , mỗi 1 directory như vậy đại diện cho 1 process.
OS sẽ quản lý process bằng cách allocate memory/cpu cycles và IO access.

  • 1 process có thể tạo nhiều sub-process
    • nhưng các process rất khó để communicate với nhau, vì nó ko share chung memory
    • phải thông qua các cách thức khác như socket(ví dụ http-socket) hoặc file để giao tiếp inter-processes.
  • 1 process có thể tạo nhiều thread
    • cách này hay được dùng
    • cùng share chung phân vùng memory
    • dễ inter-communication hơn
Đây là tiền đề để của multi-threading , application của bạn sẽ là 1 process trên OS.
OS sẽ cấp phân vùng memory/cpu cycle/io cho application.

Có thể hiểu đơn giản là application lúc chạy multi-thread, thì việc của nó là tạo các file-descriptor tương ứng trên directory /proc/<process_ID> của nó. Phần còn lại OS sẽ chịu trách nhiệm cung cấp resource. ( có thể thông qua thư viện của OS để tạo - kernel libs)

Ví dụ như Java sau khi compile ra bytecode, thì bytecode sẽ được chạy trên 1 process là JVM.
-> JVM dựa trên bytecode -> machine-code -> trên machine-code lại gọi tới kernel-libs để xin resource tương ứng.

Không phải khi nào xin resource cũng thành công, nên OS sẽ quản lý 1 mapping priority . Nếu application ko có ưu tiên cao, thì khả năng nó sẽ không được cấp CPU-cycle ( cho dù code của mình xin 8 thread ) . Đa phần thì ít khi application ko xin được CPU-cycle, đa số xin ko được thêm Memory.
Trừ các ứng dựng siêu nặng phần xử lý toán học như train AI model thì CPU 100% là có thể xảy ra việc ko còn resource CPU để cấp cho application khác.

1 lưu ý : là OS giới hạn số thread được tạo ra cho mỗi process (default trên debian 10 là 600 thread). Tức là nếu process tạo nhiều hơn mức limit, OS sẽ gửi SIGTERM hoặc SIGKILL cho process application ko tạo được thêm thread -> có khả năng bị treo app vô thời hạn (kể cả pod cũng ko health-check được vì lệnh bash để health-check ko tạo được thread :D).
Mình gặp trường hợp này khi tạo quá nhiều http-client mà không close :D (update lại là hình như ko bị gửi SIGTERM hoặc SIGKILL, mà process sẽ không tạo được thread -> có khả năng bị treo app)
 
Last edited:
3. Async/non-blocking IO được implement như thế nào ?

Ở trên có nhắc tới tại sao Async/non-blocking IO lại liên quan tới topic này.

Đơn giản là vì Async/non-blocking IO nói dễ hiểu là 1 design pattern dựa trên multi-threading/concurrency.

Mình chỉ async/non-blocking IO trên thread hiện tại -> phần sync/blocking được off-load tới 1 worker-thread ( hoặc 1 thread khác, ko phải thread hiện tại).

Lấy NodeJS built-in architecture làm ví dụ ( built-in nghĩa là support native của ngôn ngữ mà ko cần thêm 1 external lib, plugin hay extension nào cả)

1*iHhUyO4DliDwa6x_cO5E3A.gif
nguồn Medium của Rahul

Như hình trên thì event_loop kia có thể hiểu là nó được chạy trên worker-thread.
Main-thread
sẽ được trigger/interrupt chỉ khi nào callback-stack thực thi xong 1 callback nào đó.

Main-thread thì chỉ có 1 ( đây là lầm tưởng về NodeJS chỉ chạy được 1 thread) , nhưng worker-thread thì nhiều hơn 1 ( default là số core * 2 , số core thì hình như V8 lấy thừ file /proc/cpuinfo )

Luồng đơn giản:
  • Main-thread gặp callback -> vứt vào callback-stack -> tiếp tục fetch event từ event_queue ( kết quả được tính toán từ worker-thread) -> main-thread trả về kết quả
  • Worker-thread scan liên tục callback-stack -> xử lý callback -> đẩy kết quả vào event_queue
    • event_queue nhận được event mới -> trigger/interrupt main-thread ( phần này đọc doc thì dùng OS libuv )
    • trigger vs interrupt để tránh việc main-thread chỉ mải mê push callback vào stack mà ko đọc kết quả trả về
 
concurrency thì chắc go là ngon nhất nhỉ, ko biết mấy ngôn ngữ khác có thằng nào ngon hơn ko :-?

Bài này thím cố gắng viết chất lượng và đi sâu nhé, good job.
Theo ý kiến cá nhân, thì multi-threading là sub-set của concurrency , về mặt chạy trên hardware/OS thì 2 khái niệm kia gần như tương đương .

Về mặt ngôn ngữ lập trình thì theo doc của go-lang thì go-routine nó sẽ không hẳn là 1 thread. Tức là go-engine có cách nào đó để tổ chức và sắp xếp lại nhiều go-routine trên 1 thread ( của OS).
Việc khởi tạo thread cần tương tác với OS, nên đó là 1 operation rất là tốn kém và không hiệu quả.

Ngược lại như Java, thì khi gọi new Thread() -> thì thực tế JVM gọi OS tạo 1 thread cho tao .

Như go-engine thì có thể coi đó như 1 hình thái của Inverse of Control (IoC) -> dev ko cần tạo thread, tạo go-routine thôi, việc còn lại go-engine lo.
 
3. Async/non-blocking IO được implement như thế nào ?

Ở trên có nhắc tới tại sao Async/non-blocking IO lại liên quan tới topic này.

Đơn giản là vì Async/non-blocking IO nói dễ hiểu là 1 design pattern dựa trên multi-threading/concurrency.

Mình chỉ async/non-blocking IO trên thread hiện tại -> phần sync/blocking được off-load tới 1 worker-thread ( hoặc 1 thread khác, ko phải thread hiện tại).

Lấy NodeJS built-in architecture làm ví dụ ( built-in nghĩa là support native của ngôn ngữ mà ko cần thêm 1 external lib, plugin hay extension nào cả)

1*iHhUyO4DliDwa6x_cO5E3A.gif
nguồn Medium của Rahul

Như hình trên thì event_loop kia có thể hiểu là nó được chạy trên worker-thread.
Main-thread
sẽ được trigger/interrupt chỉ khi nào callback-stack thực thi xong 1 callback nào đó.

Main-thread thì chỉ có 1 ( đây là lầm tưởng về NodeJS chỉ chạy được 1 thread) , nhưng worker-thread thì nhiều hơn 1 ( default là số core * 2 , số core thì hình như V8 lấy thừ file /proc/cpuinfo )

Luồng đơn giản:
  • Main-thread gặp callback -> vứt vào callback-stack -> tiếp tục fetch event từ event_queue ( kết quả được tính toán từ worker-thread) -> main-thread trả về kết quả
  • Worker-thread scan liên tục callback-stack -> xử lý callback -> đẩy kết quả vào event_queue
    • event_queue nhận được event mới -> trigger/interrupt main-thread ( phần này đọc doc thì dùng OS libuv )
    • trigger vs interrupt để tránh việc main-thread chỉ mải mê push callback vào stack mà ko đọc kết quả trả về
Thế a thớt kết luận JS trên Nodejs là đa luồng?
 
3. Async/non-blocking IO được implement như thế nào ?

Ở trên có nhắc tới tại sao Async/non-blocking IO lại liên quan tới topic này.

Đơn giản là vì Async/non-blocking IO nói dễ hiểu là 1 design pattern dựa trên multi-threading/concurrency.

Mình chỉ async/non-blocking IO trên thread hiện tại -> phần sync/blocking được off-load tới 1 worker-thread ( hoặc 1 thread khác, ko phải thread hiện tại).

Lấy NodeJS built-in architecture làm ví dụ ( built-in nghĩa là support native của ngôn ngữ mà ko cần thêm 1 external lib, plugin hay extension nào cả)

1*iHhUyO4DliDwa6x_cO5E3A.gif
nguồn Medium của Rahul

Như hình trên thì event_loop kia có thể hiểu là nó được chạy trên worker-thread.
Main-thread
sẽ được trigger/interrupt chỉ khi nào callback-stack thực thi xong 1 callback nào đó.

Main-thread thì chỉ có 1 ( đây là lầm tưởng về NodeJS chỉ chạy được 1 thread) , nhưng worker-thread thì nhiều hơn 1 ( default là số core * 2 , số core thì hình như V8 lấy thừ file /proc/cpuinfo )

Luồng đơn giản:
  • Main-thread gặp callback -> vứt vào callback-stack -> tiếp tục fetch event từ event_queue ( kết quả được tính toán từ worker-thread) -> main-thread trả về kết quả
  • Worker-thread scan liên tục callback-stack -> xử lý callback -> đẩy kết quả vào event_queue
    • event_queue nhận được event mới -> trigger/interrupt main-thread ( phần này đọc doc thì dùng OS libuv )
    • trigger vs interrupt để tránh việc main-thread chỉ mải mê push callback vào stack mà ko đọc kết quả trả về
Cho mình hỏi cái event queue bạn đề cập vs cái callback queue nó là 1 hay là 2 khái niệm khác nhau vậy?
 
3. Async/non-blocking IO được implement như thế nào ?

Ở trên có nhắc tới tại sao Async/non-blocking IO lại liên quan tới topic này.

Đơn giản là vì Async/non-blocking IO nói dễ hiểu là 1 design pattern dựa trên multi-threading/concurrency.

Mình chỉ async/non-blocking IO trên thread hiện tại -> phần sync/blocking được off-load tới 1 worker-thread ( hoặc 1 thread khác, ko phải thread hiện tại).

Lấy NodeJS built-in architecture làm ví dụ ( built-in nghĩa là support native của ngôn ngữ mà ko cần thêm 1 external lib, plugin hay extension nào cả)

1*iHhUyO4DliDwa6x_cO5E3A.gif
nguồn Medium của Rahul

Như hình trên thì event_loop kia có thể hiểu là nó được chạy trên worker-thread.
Main-thread
sẽ được trigger/interrupt chỉ khi nào callback-stack thực thi xong 1 callback nào đó.

Main-thread thì chỉ có 1 ( đây là lầm tưởng về NodeJS chỉ chạy được 1 thread) , nhưng worker-thread thì nhiều hơn 1 ( default là số core * 2 , số core thì hình như V8 lấy thừ file /proc/cpuinfo )

Luồng đơn giản:
  • Main-thread gặp callback -> vứt vào callback-stack -> tiếp tục fetch event từ event_queue ( kết quả được tính toán từ worker-thread) -> main-thread trả về kết quả
  • Worker-thread scan liên tục callback-stack -> xử lý callback -> đẩy kết quả vào event_queue
    • event_queue nhận được event mới -> trigger/interrupt main-thread ( phần này đọc doc thì dùng OS libuv )
    • trigger vs interrupt để tránh việc main-thread chỉ mải mê push callback vào stack mà ko đọc kết quả trả về
Nodejs có worker threads ko có nghĩa nó multi-threading đâu, cái này là term của nó thôi. IIRC. Còn đó giờ cải tiến gì chưa thì ko biết, dự là ko, ko thể.

Tuy nhiên phần nội dung về hardware khá hay, tặng 1 vỗ tay.
 
Tôi rất ủng hộ a thớt viết bài. Vừa đóng góp cho cộng đồng vừa để tranh luận.
Chỉ có điều tôi góp ý là Nodejs sử dụng worker threads chỉ đơn thuần là gọi các process bên ngoài từ only main thread thôi. Chẳng có gì là phức tạp và khó hiểu.

Bản chất worker threads là module của C/C++ nó không phải core của Nodejs nên làm sao gọi nó là thread của Nodejs được. Nguyên tắc thread là phải trong core của ngôn ngữ để còn đảm bảo synchronized về mặt toán học. Maths của C/C++ nó khác với JS (Nodejs).

Tóm lại: Nodejs + worker threads + muti cores = parallel
 
Last edited:
Sorry chủ thớt nhưng mình thấy một số ý của bạn trong phần Hardware có vẻ chưa thực sự chính xác. Mình sẽ giải thích lại theo cách hiểu của mình như sau:
  • CPU được tạo ra bằng cách kết nối các cổng logic. Các cổng logic này hoạt động dựa trên xung nhịp. Muốn tăng tốc độ của phần mềm thì cần giảm thời gian thực hiện 1 câu lệnh xuống bằng cách tăng xung nhịp lên. Tuy nhiên khi tăng xung nhịp thì sẽ dẫn đến tiêu hao điện năng và giảm tuổi thọ của CPU. Do đó người ta mới nghĩ ra kiến trúc pipeline để giải quyết chuyện này. Bằng cách chia nhỏ các bước trong một câu lệnh ra và các bước này sẽ có thể được làm overlap vào nhau. Thực tế nó có thể overlap không tùy tùy từng trường hợp cụ thể. Hardware nó sẽ tự xử lý để tối ưu (branch predictor). Ngoài ra thì compiler cũng giúp tăng được lượng overlap lên bằng cách reorder các câu lệnh để giải quyết một số trường hợp liên quan đến data dependency (Optimizing compiler). Điều này giúp tăng throughput tuy nhiên cũng làm tăng latency. Nó không thực sự liên quan đến khả năng concurency. Vì kể cả có chạy 1 thread duy nhất thì pipeline vẫn giúp tăng throughput. :big_smile:
  • Kiến trúc hiện đại nó có nhiều thứ hay ho hơn.
    • Đầu tiên phải kể đến là SIMD instruction. Cái này giống như thay vì cộng từng phần tử giữa 2 vector thì nó cộng nhiều phần tử trong 1 câu lệnh. 1 dạng song song nhưng tại instruction level. Anh em nào có xài numpy trong python và thắc mắc vì sao nó chạy nhanh thì 1 phần là do cái này. Ngoài ra còn do tối ưu về cache nữa.
    • Tiếp theo phải kể để là hyper threading. Chắc ae đều đã nghe qua mấy cái quảng cáo dạng như 2 nhân 4 luồng. Cái này dạng như 1 core nhưng nó có ít nhất là 2 cho mỗi thanh ghi đặc biệt (vd: program counter) để giữ được context của 2 thread khác nhau. Giả sử thread1 đang chờ load data từ main memory do bị miss cache,... Thì thread2 sẽ được thực thi. Do đó nó giúp tận dụng tối đa thời gian của CPU.
 
sẵn đây có cao nhân nào thông não cho em về co-routine của Kotlin với. Vì sao nó có thể chạy nhiều co-rountine trên main-thread mà ko block main-thread vậy nhỉ
 
Hmm...Topic hay
Vấn đề này cần được xem xét thêm khía cạnh kĩ thuật lập trình, ngữ cảnh thực tế, và quan trọng nhất là kết quả cuối cùng.
- Lấy vd nền tảng API web service. Nếu bạn có 1 khối lượng lớn data, tầm 1trieu dòng chẳng hạn, và 1tr dòng này cần được call lên API để validate.
-Ý tưởng cơ bản là viết 1 tool, tạo thật nhiều thread call đến API nhiều nhất có thể, ít nhất là đến khi có thread trả về exception error. Kĩ thuật code cần phải kĩ, vì thực tế nó còn có các thao tác khác như update kết quả về DB nữa, số lượng connection tới DB cũng là 1 vấn đề.
- Loại 2 cũng là 1 tool chỉ có 1 thread, nhưng clone ra nhiều instance của tool này cho đến khi xuất hiện các exception error. Loại này ưu điểm là tạo nhiều instance cho phép dễ scale trên nhiều server, đặc biệt là khắc phục được vụ giới hạn IP của web service, và cũng dễ code nữa. Khi cần có thể turn off bớt instance khá đơn giản
 
Last edited:
4. Vai trò của lambda function vs functional programming trong Async/non-blocking IO và multi-threading

Phần này sẽ đi sâu vào cách implement trong Java làm ví dụ:

Java:
final ExecutorService executorService = Executors.newSingleThreadExecutor();

executorService.execute(() -> {
            // Executed in another thread

           // Thread-scope
           // Dedicated stack-space (default 1MB on memory for each Thread)
});

// If the the task done -> then close (not closed yet)
executorService.shutdown();

// Block the current thread -> wait until the task done
if (executorService.awaitTermination(waitDuration, WAIT_TIME_UNIT)) {
            logger.info("onEnable init Ignite service successfully");
    // Success
} else {
    // Fail -> the task not finish on-time
   
    // Force the the thread to close
    executorService.shutdownNow();
}

Thread-scope là yếu tố tạo nên sự liên quan giữa lambda function/callback vs async/non-blocking IO.

  1. Các biến primitive datatype ( như int,long,boolean...) được lưu trên stack-memory của thread hiện tại, sẽ không cho phép share qua 1 thread(stack-memory) khác - trừ biến final
  2. Để truyền vào thread-scope khác, cần phải dùng wrapper-datatype ( Integer, Long, Boolean) hoặc Object được shared qua Heap-space. Lưu ý, về lý thuyết thì share được , nhưng sẽ không đảm bảo thread-safe, và compiler cũng bắt buộc phải là biến final ( trên stack lưu address -> heap-memory, thì phải final cái address này rồi mới truyền vào thread-scope khác, không cho phép override lại )
  3. Khi truyền biến vào 1 thread-scope khác, cần đảm bảo sẽ không có race-condition (thread-safe object) hoặc chỉ có 1 thread duy nhất access vào nó.
Giải thích sơ qua lý do tại sao có các ràng buộc trên:
  • Về mặt hardware, khi CPU xử lý machine-code, thì nó fetch data từ register -> L1,L2,L3 ... cache -> memory
  • Và mỗi core lại có 1 phân vùng L1 vs L2 riêng, có thể L3 lại chung (đọc thêm về MESI protocol) -> giá trị của 1 memory-address có thể bị dirty-cache (khác nhau giữa core vs core kia)
    • với point 1 và 2 ở trên thì nếu compiler cho phép truyền trực tiếp memory-address từ thread này sang thread khác -> việc update memory có thể dẫn đến race-condition hoặc unknow-condition
  • Để bắt buộc CPU check dirty-cache trước khi fetch giá trị -> dùng keyword volatile (tương tự như keyword register của C++) cho attribute muốn check -> ảnh hưởng đến performance rất lớn
  • Việc đảm bảo chỉ 1 thread duy nhất được tác động vào 1 Object -> forEach() , một vài stream api
    • hoặc có thể dùng keyword synchonized cho method -> đảm bảo chỉ có 1 thread duy tại 1 thời điểm có thể gọi vào method đó
Lambda function hay functional programming đều rất hạn chế mutate global var , best practice thì chỉ dùng pure-function ( không sửa xóa các vùng share).
Cá nhân mình thì hay dùng các thread-safe object ví dụ: BlockingQueue, ConccurentMap vs Atomic để sync giữa các thread.

Thread-safe object thường dùng cơ chế như spin-lock, semaphore hoặc đặc biệt như class Atomic thì support ở tầng assembly ( có các lệnh để tự sync value - update là dùng volatile + Compare and Swap - CAS )
 
Last edited:
Thế a thớt kết luận JS trên Nodejs là đa luồng?
Đúng mà cũng không đúng.

Như ví dụ về go-lang mình trả lời ở đây
Theo ý kiến cá nhân, thì multi-threading là sub-set của concurrency , về mặt chạy trên hardware/OS thì 2 khái niệm kia gần như tương đương .

Về mặt ngôn ngữ lập trình thì theo doc của go-lang thì go-routine nó sẽ không hẳn là 1 thread. Tức là go-engine có cách nào đó để tổ chức và sắp xếp lại nhiều go-routine trên 1 thread ( của OS).
Việc khởi tạo thread cần tương tác với OS, nên đó là 1 operation rất là tốn kém và không hiệu quả.

Ngược lại như Java, thì khi gọi new Thread() -> thì thực tế JVM gọi OS tạo 1 thread cho tao .

Như go-engine thì có thể coi đó như 1 hình thái của Inverse of Control (IoC) -> dev ko cần tạo thread, tạo go-routine thôi, việc còn lại go-engine lo.
Thì về mặt hardware, NodeJS đã sử dụng multi-thread để xử lý .

Không đúng là ở mặt programming, mình không khởi tạo thread manually được , mà phải ràng buộc bằng cách khác như tạo Promise/callback -> force cho nó được chạy ở worker-thread.

Tại sao nên ràng buộc cứng như vậy thì là do idea của người tạo ra ngôn ngữ đó.
 
Back
Top