Hệ điều hành & Tiến trình/Thread vs process — chia sẻ gì, riêng gì, chi phí tạo
16/28
Bài 16 / 28~13 phútThread & Lập lịch CPUMiễn phí lượt xem

Thread vs process — chia sẻ gì, riêng gì, chi phí tạo

Thread là gì, vì sao thread cùng process chia sẻ heap nhưng mỗi thread có stack riêng, chi phí tạo thread vs process, và khi nào chọn cái nào.

TL;DR: Thread là một luồng thực thi bên trong một tiến trình. Nhiều thread trong cùng một process chia sẻ không gian địa chỉ: cùng code, cùng dữ liệu toàn cục, cùng heap, cùng bảng file descriptor. Cái mỗi thread giữ riêng là thứ cần để chạy độc lập: stack riêng (biến cục bộ, chuỗi lời gọi hàm) và bộ register riêng (gồm program counter và stack pointer). Vì chia sẻ address space, tạo một thread rẻ hơn tạo một process khoảng một bậc, và giao tiếp giữa các thread chỉ là đọc/ghi cùng biến. Process cho cách ly (một process sập không kéo process khác chết); thread cho song song trên bộ nhớ chung với chi phí thấp.

Máy chủ của bạn nhận 10.000 kết nối cùng lúc. Mô hình cũ (Apache prefork) tạo một tiến trình cho mỗi kết nối: 10.000 process, mỗi cái một bản sao không gian địa chỉ, mỗi cái tốn vài trăm KB tới hàng MB bộ nhớ kernel cho page table và cấu trúc quản lý. Máy hết RAM trước khi hết CPU. Mô hình mới tạo một thread cho mỗi kết nối trong cùng một process: 10.000 thread chia sẻ một address space, dùng chung cache dữ liệu, tạo nhanh hơn nhiều. Cùng một bài toán, khác nhau ở một quyết định: dùng process hay thread.

Bài này giải thích thread khác process ở đâu — chính xác cái gì chia sẻ, cái gì riêng, vì sao thiết kế như vậy, và khi nào bạn nên chọn cái nào. Học xong, bạn Compare được thread và process trên ba trục — chia sẻ gì, riêng gì, chi phí tạo — và biết chọn cái nào cho một bài toán cụ thể. Đây là nền cho mọi bài còn lại trong module: context switch, scheduler, và chọn số thread đều xây trên hiểu biết này.

1. Analogy — công ty và nhân viên chung một văn phòng

Hình dung một process là một công ty thuê nguyên một tầng văn phòng. Thread là các nhân viên làm việc trong công ty đó.

Mọi nhân viên dùng chung cơ sở vật chất của tầng: cùng kho tài liệu (heap), cùng bảng thông báo dán ngoài sảnh (dữ liệu toàn cục), cùng danh bạ điện thoại của công ty (bảng file descriptor). Ai cũng đọc và sửa được kho tài liệu chung — tiện, nhưng nếu hai người cùng sửa một hồ sơ mà không hẹn nhau thì hồ sơ hỏng.

Nhưng mỗi nhân viên có bàn làm việc riêng: giấy nháp đang viết dở (biến cục bộ), danh sách việc đang làm theo thứ tự (chuỗi lời gọi hàm), và bút đang cầm trên tay chỉ vào dòng đang đọc (program counter). Không ai ngồi chung bàn được, vì ai cũng đang làm một mạch việc riêng.

Một process khác là một công ty khác thuê tầng khác. Hai công ty không thấy kho tài liệu của nhau; muốn trao đổi phải gửi thư ra ngoài (IPC — bài của module 04). Công ty này cháy thì công ty kia vẫn an toàn.

Đời thườngKhái niệm
Công ty thuê một tầngProcess (một không gian địa chỉ)
Nhân viên trong công tyThread
Kho tài liệu chung, bảng thông báoHeap + dữ liệu toàn cục (chia sẻ)
Danh bạ điện thoại công tyBảng file descriptor (chia sẻ)
Bàn làm việc riêng, giấy nháp, bút chỉ dòngStack + register riêng mỗi thread
Công ty khác thuê tầng khácProcess khác (address space riêng)
Gửi thư giữa hai công tyIPC giữa hai process
💡 Cách nhớ

Thread chia sẻ tài nguyên của process (code, heap, file), nhưng giữ riêng trạng thái thực thi (stack, register). Câu một dòng: "chung nhà, riêng bàn".

2. Chia sẻ gì, riêng gì — theo đúng chuẩn POSIX

Trước hết định nghĩa cho chặt. Một process là một chương trình đang chạy, sở hữu một không gian địa chỉ ảo (virtual address space) riêng — vùng nhớ mà chỉ nó thấy (khoá Bộ nhớ, bài bộ nhớ ảo). Một thread (POSIX gọi là pthread) là một luồng thực thi bên trong một process. Một process luôn có ít nhất một thread (thread main); nó có thể tạo thêm nhiều thread nữa, tất cả sống trong cùng address space đó.

Chuẩn POSIX pthreads(7) liệt kê rõ cái gì chia sẻ giữa các thread cùng process và cái gì riêng:

Chia sẻ (chung cả process)Riêng mỗi thread
Code (text segment)Thread ID
Dữ liệu toàn cục + heapStack (biến cục bộ)
Bảng file descriptor (file đang mở)Bộ register (gồm program counter, stack pointer)
Signal disposition (handler cho mỗi signal)Signal mask (signal nào đang chặn)
Thư mục hiện tại, umask, nice valueerrno
Process ID, parent PIDAlternate signal stack, CPU affinity

Lưu ý nhỏ về nice value: chuẩn POSIX xếp nó vào nhóm chia sẻ cả process, nhưng Linux hiện thực nó per-thread — mỗi thread có nice riêng. Nếu bạn dựa vào nice để chỉnh ưu tiên trên Linux, nhớ nó tác động từng thread, không phải cả process. Chi tiết ở phần NOTES của pthreads(7).

Điểm mấu chốt để nhớ: thứ thuộc về "chương trình" thì chung; thứ thuộc về "một luồng đang chạy tới đâu" thì riêng. Code là của chương trình nên chung. Heap chứa object dùng chung nên chung. Nhưng "tôi đang thực thi dòng nào, gọi hàm nào, biến cục bộ ra sao" là của riêng từng luồng — nên stack và register phải riêng.

Sơ đồ bố cục bộ nhớ của một process có hai thread — nối thẳng với mô hình stack/heap bạn đã học ở khoá Bộ nhớ:

Process — mot khong gian dia chi, hai thread
Stack thread 1 (rieng)
bien cuc bo, khung goi ham cua thread 1
Stack thread 2 (rieng)
bien cuc bo, khung goi ham cua thread 2
Heap (chia se)
object cap phat dong — moi thread deu doc/ghi
Du lieu toan cuc / static (chia se)
Code / text segment (chia se)

Chú ý: chỉ có một heap, một vùng code — dùng chung. Nhưng có hai stack, mỗi thread một cái. Đây là hình ảnh trung tâm của cả bài.

3. Cơ chế bên dưới — vì sao stack và register phải riêng

Câu hỏi hay: tại sao bắt buộc mỗi thread phải có stack và register riêng, trong khi heap thì chung được?

Một CPU core tại một thời điểm đang thực thi đúng một luồng lệnh. Để "biết mình đang ở đâu", nó dùng vài register đặc biệt:

  • Program counter (PC) — địa chỉ lệnh kế tiếp sẽ chạy.
  • Stack pointer (SP) — đỉnh stack hiện tại, tức khung hàm đang hoạt động.
  • Các register dữ liệu khác đang giữ giá trị tính toán dở.

Nếu hai thread cùng một luồng lệnh nhưng đang ở hai chỗ khác nhau (thread 1 đang trong parseRequest(), thread 2 đang trong writeResponse()), chúng phải có hai bộ PC/SP khác nhau. Chung một PC là vô lý — không thể vừa ở dòng này vừa ở dòng kia. Vì vậy mỗi thread giữ riêng bộ register.

Stack cũng vậy. Stack lưu biến cục bộ và khung của từng lời gọi hàm (stack frame ở khoá Bộ nhớ). Hai thread gọi hàm độc lập nhau, chuỗi lời gọi khác nhau, biến cục bộ khác nhau — nên mỗi thread cần một stack riêng. Nếu dùng chung stack, lời gọi hàm của thread này sẽ giẫm lên khung của thread kia.

Ngược lại, heap chứa object "của chương trình", không gắn với một luồng cụ thể. Thread 1 tạo một object trên heap, thread 2 hoàn toàn có thể đọc nó — đó chính là cách hai thread chia sẻ dữ liệu. Không có lý do bắt heap phải riêng, và làm riêng thì mất luôn khả năng chia sẻ rẻ tiền.

flowchart TB
  subgraph P["Process — 1 address space"]
    CODE["Code + Heap + Data<br/>(chia se)"]
    subgraph T1["Thread 1"]
      R1["PC, SP, registers 1"]
      S1["Stack 1"]
    end
    subgraph T2["Thread 2"]
      R2["PC, SP, registers 2"]
      S2["Stack 2"]
    end
  end
  T1 --> CODE
  T2 --> CODE

Trên Linux, sự chia sẻ này không phải phép màu — nó là tham số của một syscall. Tạo process dùng fork() (bản sao address space); tạo thread dùng clone() với cờ CLONE_VM (dùng chung không gian nhớ), CLONE_FILES (dùng chung bảng fd), CLONE_FS... Thư viện pthread gọi clone() với đúng bộ cờ này. Nói cách khác, với kernel Linux, thread chỉ là "một tiến trình chia sẻ gần hết mọi thứ với anh em của nó".

4. Chi phí tạo: thread rẻ hơn process khoảng một bậc

Vì thread không phải dựng một address space mới, tạo thread rẻ hơn tạo process đáng kể.

Khi fork() một process, kernel phải lập một không gian địa chỉ mới: sao chép bảng ánh xạ trang (page table), thiết lập cấu trúc quản lý bộ nhớ, sao chép bảng fd... Linux dùng copy-on-write để không sao chép ngay từng trang dữ liệu (chỉ sao khi bị ghi), nên fork() không quá đắt, nhưng vẫn phải dựng và sao chép page table — công việc tỉ lệ với kích thước không gian địa chỉ.

Khi tạo thread, kernel chỉ cần: cấp một stack mới, tạo một cấu trúc mô tả thread, và đăng ký nó với scheduler. Không dựng address space, không sao page table.

Kết quả thực nghiệm (đo bằng lmbench hoặc microbenchmark tương tự trên Linux x86-64 phổ thông) cho biên độ như sau — coi là bậc độ lớn, không phải hằng số tuyệt đối vì phụ thuộc CPU, kernel, kích thước process:

Thao tácBiên độ điển hình
Tạo + huỷ một thread (pthread_create)Hàng chục µs
Tạo + huỷ một process (fork + exit)Hàng trăm µs tới ~1 ms cho tiến trình phổ thông (nặng hơn nếu address space lớn)

Tức tạo thread thường rẻ hơn tạo process khoảng một bậc (chục lần), không phải nghìn lần. Con số cụ thể trên máy bạn sẽ khác; điều cần nhớ là hướng và biên độ: thread nhẹ hơn process một cách có hệ thống, và khoảng cách lớn dần khi process có address space lớn (fork phải sao nhiều page table hơn).

Đừng suy ra 'thread luôn nhanh hơn'

Rẻ khi tạo không có nghĩa là chạy nhanh hơn. Khi chạy, cả thread và process đều được scheduler cấp CPU như nhau. Thread có thêm cái lợi lúc context switch giữa hai thread cùng process: không phải đổi address space nên không flush TLB — bài 02 sẽ mổ kỹ. "Thread luôn nhanh hơn process" là hiểu sai; đúng hơn là "thread rẻ hơn khi tạo và khi chuyển đổi trong cùng process".

5. Khi nào chọn process, khi nào chọn thread?

Không có bên nào thắng tuyệt đối — chọn theo bài toán.

Tình huốngNên dùngVì sao
Cần cách ly lỗi: một phần sập không kéo phần khác chếtProcessMỗi process address space riêng; crash một cái, cái khác vẫn sống
Cần cách ly bảo mật giữa các thành phầnProcessRanh giới bộ nhớ do kernel enforce; thread cùng process thấy hết bộ nhớ của nhau
Song song trên cùng dữ liệu lớn (xử lý một mảng, một cache chung)ThreadChia sẻ heap: không phải copy dữ liệu qua lại, giao tiếp gần như miễn phí
Nhiều tác vụ nhẹ, tạo/huỷ liên tụcThreadTạo rẻ hơn một bậc; process pool đắt hơn nhiều
Chạy chương trình khác hẳn (trình con, plugin không tin cậy)Processfork + exec để nạp binary khác; sandbox được

Ví dụ thực tế: trình duyệt Chrome cho mỗi tab thường một renderer process riêng (chính sách Site Isolation; Chrome có thể gộp nhiều tab vào chung một process khi máy thiếu RAM) để một tab treo hay bị khai thác không hạ cả trình duyệt và không đọc được dữ liệu tab khác — đổi lấy chi phí bộ nhớ cao hơn. Ngược lại, một web server Java xử lý request bằng thread pool vì các request chia sẻ cache, connection pool, config trong cùng heap, và cần tạo/thu hồi nhanh.

6. Pitfall của riêng concept này

Nhầm 1 — tưởng mỗi thread có heap riêng:

// Thread A
sharedList.add("x");   // sharedList o tren HEAP CHUNG

// Thread B doc cung luc
int n = sharedList.size();   // thay thay doi cua A ngay -- CHUNG HEAP

✅ Chỉ có một heap cho cả process. sharedList mà cả hai thread cùng tham chiếu là một object duy nhất — B thấy thay đổi của A. Đây vừa là sức mạnh (chia sẻ rẻ) vừa là bẫy: nếu A và B cùng add không đồng bộ, cấu trúc nội bộ của list hỏng. Đồng bộ hoá là chủ đề module 04.

Nhầm 2 — tưởng biến cục bộ cũng bị chia sẻ:

void handle() {
    int count = 0;        // bien cuc bo -> nam tren STACK RIENG cua moi thread
    count++;              // moi thread co ban 'count' rieng, khong dam nhau
}

✅ Biến cục bộ nằm trên stack, mà stack thì riêng mỗi thread. Hai thread cùng chạy handle() có hai biến count độc lập — không cần đồng bộ. Quy tắc: dữ liệu trên stack (cục bộ) thì an toàn tự nhiên; dữ liệu trên heap (chia sẻ) mới cần đồng bộ.

Nhầm 3 — tưởng một thread crash chỉ chết thread đó:

✅ Một lỗi nghiêm trọng không bắt được (ví dụ segfault do thao tác bộ nhớ sai trong native code) làm sập cả process — kéo theo mọi thread. Vì mọi thread chung một address space, hỏng bộ nhớ ở một thread có thể phá dữ liệu của thread khác. Đây chính là lý do khi cần cách ly lỗi thật sự, người ta chọn process, không phải thread.

7. 📚 Deep Dive Linux

📚 Deep Dive (tuỳ chọn)

Spec / man page chính thức:

  • pthreads(7) — man page liệt kê đầy đủ thuộc tính chia sẻ và riêng giữa các thread POSIX (nguồn của bảng ở mục 2). Đọc phần "the following are shared" và "distinct attributes".
  • clone(2) — syscall tạo luồng thực thi trên Linux; các cờ CLONE_VM, CLONE_FILES, CLONE_FS chính là công tắc bật/tắt việc chia sẻ. Thread = clone với gần hết cờ bật; process = fork (cơ bản không bật cờ chia sẻ).
  • fork(2) — tạo process con bằng bản sao (copy-on-write) address space cha.

Ghi chú: trên Linux, thread và process không phải hai cơ chế tách biệt — cả hai đều là "task" với các mức chia sẻ khác nhau, đều do clone() tạo ra. Hiểu điều này giúp bạn thôi coi thread là "phép màu": nó chỉ là một task chia sẻ address space với anh em.

8. Liên hệ các bài khác

9. Tóm tắt

  • Thread là luồng thực thi trong một process; một process có ít nhất một thread và có thể tạo thêm.
  • Thread cùng process chia sẻ code, dữ liệu toàn cục, heap, bảng file descriptor — mọi thứ thuộc về "chương trình".
  • Mỗi thread giữ riêng stack và bộ register (gồm program counter và stack pointer) — mọi thứ thuộc về "một luồng đang chạy tới đâu".
  • Stack/register phải riêng vì một CPU core chỉ ở được một chỗ tại một thời điểm; heap chung được vì object không gắn với luồng cụ thể.
  • Tạo thread rẻ hơn tạo process khoảng một bậc (chục µs so với trăm µs tới ~1 ms) vì không phải dựng và sao chép address space.
  • Chọn process khi cần cách ly lỗi/bảo mật; chọn thread khi cần song song rẻ trên bộ nhớ chung.
  • Chia sẻ heap khiến giao tiếp rẻ nhưng đòi hỏi đồng bộ; một thread làm sập bộ nhớ có thể kéo cả process chết.

10. Tự kiểm tra

Tự kiểm tra
Q1
Vì sao thread chia sẻ được heap nhưng PHẢI có stack riêng? Điều gì vỡ nếu hai thread dùng chung một stack?
Heap chứa object "của chương trình" — không gắn với một luồng cụ thể, nên nhiều thread đọc/ghi chung được; đó chính là cách chúng chia sẻ dữ liệu rẻ tiền (thread 1 tạo object, thread 2 đọc ngay). Stack thì phải riêng vì nó lưu trạng thái thực thi của một luồng: chuỗi khung lời gọi hàm đang hoạt động và biến cục bộ của từng lời gọi. Hai thread gọi hàm độc lập nhau, ở độ sâu khác nhau, giữ biến cục bộ khác nhau. Nếu chung một stack, lời gọi hàm của thread này sẽ ghi đè lên khung của thread kia: địa chỉ trả về bị hỏng (thread trả về sai chỗ rồi crash), biến cục bộ giẫm lên nhau, và stack pointer của hai luồng tranh nhau cùng một đỉnh — mỗi thread không còn là một luồng thực thi độc lập. Nguồn chuẩn: pthreads(7).
Q2
Vì sao mỗi thread bắt buộc phải có program counter và stack riêng, trong khi heap thì chia sẻ được?
Một CPU core tại một thời điểm chỉ thực thi được một luồng lệnh, "biết mình ở đâu" nhờ program counter (địa chỉ lệnh kế tiếp) và stack pointer (khung hàm đang chạy). Hai thread thường đang ở hai vị trí khác nhau trong code (thread 1 trong parseRequest, thread 2 trong writeResponse) — không thể dùng chung một program counter vì không thể vừa ở dòng này vừa ở dòng kia. Stack cũng phải riêng vì mỗi thread có chuỗi lời gọi hàm và biến cục bộ độc lập; dùng chung thì lời gọi của thread này giẫm lên khung của thread kia. Ngược lại, heap chứa object "của chương trình", không gắn với luồng nào cụ thể — thread 1 tạo object, thread 2 đọc được, đó chính là cách chia sẻ dữ liệu. Bắt heap riêng thì mất luôn lợi ích chia sẻ mà chẳng được gì.
Q3
Vì sao tạo một thread rẻ hơn tạo một process? Chênh lệch cỡ bao nhiêu, và điều gì làm chênh lệch đó lớn hơn?
Tạo process (fork) phải dựng một không gian địa chỉ mới: lập và sao chép page table, thiết lập cấu trúc quản lý bộ nhớ, sao bảng file descriptor. Linux dùng copy-on-write nên không sao ngay dữ liệu, nhưng vẫn phải sao page table — công việc tỉ lệ với kích thước address space. Tạo thread (clone với CLONE_VM) không dựng address space: chỉ cấp một stack, tạo cấu trúc mô tả thread, đăng ký với scheduler. Chênh lệch điển hình khoảng một bậc — thread hàng chục µs, process hàng trăm µs tới ~1 ms (coi là biên độ, không phải hằng số). Chênh lệch lớn hơn khi process có address space lớn: fork phải sao nhiều page table hơn, còn tạo thread thì gần như không đổi.
Q4
Chrome dùng một process cho mỗi tab, còn một web server Java dùng thread pool. Giải thích vì sao mỗi bên chọn khác nhau.
Chrome ưu tiên cách ly: mỗi tab một renderer process riêng, address space riêng do kernel enforce. Một tab treo, sập, hay bị khai thác lỗ hổng thì không hạ cả trình duyệt và không đọc được bộ nhớ của tab khác. Giá phải trả là mỗi process tốn thêm bộ nhớ (page table, cấu trúc quản lý) — Chrome chấp nhận đánh đổi đó vì an toàn quan trọng hơn. Web server Java thì ngược lại: các request cần chia sẻ cache, connection pool, config nằm trong cùng heap; dùng thread giúp truy cập chung gần như miễn phí thay vì copy dữ liệu qua ranh giới process. Request cũng đến/đi liên tục nên tạo/thu hồi thread rẻ (một bậc) là lợi thế lớn. Tóm lại: cần cách ly lỗi/bảo mật thì chọn process; cần song song rẻ trên dữ liệu chung thì chọn thread.
Q5
Trong một hàm xử lý request chạy bởi nhiều thread, biến cục bộ int count và một ArrayList trên heap dùng chung — cái nào cần đồng bộ, cái nào không? Vì sao?
Biến cục bộ count không cần đồng bộ: nó nằm trên stack, mà mỗi thread có stack riêng. Hai thread cùng chạy hàm đó có hai bản count hoàn toàn độc lập, không thể giẫm nhau. ArrayList dùng chung trên heap thì cần đồng bộ: chỉ có một object duy nhất mà mọi thread cùng tham chiếu, nên hai thread cùng add đồng thời có thể làm hỏng cấu trúc nội bộ (mảng backing, biến size) — kinh điển của race condition. Quy tắc rút ra: dữ liệu trên stack (cục bộ) an toàn tự nhiên vì riêng từng thread; dữ liệu trên heap (chia sẻ) mới cần cơ chế đồng bộ. Đây là lý do một hàm chỉ dùng biến cục bộ thường "thread-safe" mà không cần làm gì thêm.
Q6
Một đồng nghiệp nói 'cứ dùng thread thay process đi cho nhanh, thread luôn nhanh hơn'. Câu này sai ở đâu?
Sai vì trộn hai chuyện: chi phí tạo/chuyển đổitốc độ chạy. Thread rẻ hơn khi tạo (một bậc) và khi context switch trong cùng process (không flush TLB — bài 02). Nhưng khi đã chạy, scheduler cấp CPU cho thread và process như nhau; thread không tự nhiên "tính toán nhanh hơn". Quan trọng hơn, "nhanh" không phải tiêu chí duy nhất: thread không có cách ly — một thread làm hỏng bộ nhớ hoặc gây segfault kéo sập cả process và mọi thread khác, và mọi dữ liệu chia sẻ cần đồng bộ nếu không muốn race condition. Khi cần cách ly lỗi hoặc bảo mật (Chrome tab, chạy code không tin cậy), process là lựa chọn đúng dù "chậm hơn" khi tạo. Chọn theo yêu cầu cách ly vs chia sẻ, không theo câu thần chú "thread luôn nhanh hơn".

Bài tiếp theo: Ready, running, blocked — và giá của context switch

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên