Thực hành tốt nhất về đa luồng: ấn bản C++ — loại bỏ sự cố bằng cấu trúc với RAII và jthread

· · Windows, Đa luồng, C++, Visual Studio, Ứng dụng nghiệp vụ, Điều tra lỗi, Thiết kế

«Một thiết kế chạy ổn trong C# bắt đầu thỉnh thoảng crash sau khi chúng tôi chuyển sang C++.» «Chúng tôi dùng std::thread, và khi một ngoại lệ được ném, cả ứng dụng chết ngay lập tức qua terminate.» «Chúng tôi dừng mọi thứ bằng cờ volatile bool, nhưng chỉ ở bản phát hành, nó không dừng.» — Đa luồng C++ mang một mối nguy mà ngôn ngữ quản lý đơn giản không có: một data race, đúng như vậy, là hành vi không xác định (UB). Không chỉ là bạn có thể đọc một giá trị hỏng; các giả định tối ưu của trình biên dịch sụp đổ, và bạn rơi vào trạng thái mà đúng nghĩa là bất cứ điều gì cũng có thể xảy ra.

Bài này là ấn bản C++ của loạt đa luồng thực hành. Hướng tới những người viết ứng dụng nghiệp vụ, phần mềm điều khiển thiết bị và DLL bằng C++ hiện đại (C++17/20), bài chuyển các nguyên tắc chung của thiết kế đa luồng — đừng thêm luồng trực tiếp, giảm trạng thái biến đổi dùng chung, áp dụng kỷ luật khóa, thiết kế cách dừng trước mọi thứ — sang công cụ C++ và Windows, cùng những bẫy riêng của C++, tất cả dựa trên nguồn gốc tính đến tháng 8 năm 2026. Được viết để đứng độc lập. Cùng những nguyên tắc đó, được triển khai cho ngôn ngữ khác, cũng có trong «Ấn bản .NET», «Ấn bản C» và «Ấn bản Java».

1. Kết luận trước

  • Trong C++, data race không phải «bạn có thể đọc giá trị hỏng» — đó là hành vi không xác định. Không để sót dù chỉ một truy cập biến đổi dùng chung không đồng bộ ở bất kỳ đâu trong mã là yêu cầu tuyệt đối, hơn cả các ngôn ngữ khác.1
  • Đừng dùng std::thread trần. Nếu hàm hủy của std::thread chạy trong lúc luồng vẫn còn joinable, std::terminate giết tiến trình ngay. std::jthread của C++20 tự join trong hàm hủy và có sẵn cơ chế yêu cầu dừng (stop_token).23
  • Luôn giữ khóa qua RAII. Ngừng viết tay mtx.lock(); hãy dùng lock_guard / scoped_lock. Hàm hủy giải phóng khóa đáng tin cậy ngay cả khi ngoại lệ được ném. Khi chiếm vài khóa cùng lúc, scoped_lock lo việc đó bằng thuật toán tránh deadlock.4
  • volatile không phải công cụ đồng bộ. Dùng std::atomic cho cờ và bộ đếm dùng chung, và std::mutex để bảo vệ nhiều biến cùng lúc. std::atomic vừa cho tính nguyên tử vừa cho thứ tự dựa trên memory_order.5
  • Chờ rendezvous bằng dạng vị từ của wait trên condition_variable. Biến điều kiện chịu thức tỉnh giả (thức mà không được báo), nên gọi wait không có vị từ là mảnh đất nuôi bug.6
  • jthread + stop_token (C++20) là hình dạng cơ bản để dừng một luồng. Ở môi trường trước đó, hãy tự xây dừng hợp tác bằng std::atomic<bool> cộng biến điều kiện. Hãy xem việc buộc kết thúc luồng là thứ đơn giản không tồn tại trong thế giới C++.3
  • Hãy biết hàm hủy của future có thể chặn trước khi dùng std::async. Bỏ giá trị trả về thì bạn nhận đúng hiệu ứng của thực thi tuần tự.7
  • Đối tượng đồng bộ Win32 chỉ đáng chỗ cho kịch bản «làm việc với API chờ Win32» và «xuyên tiến trình». Mọi nơi khác, viết theo thư viện chuẩn tốt hơn cho tính di động và bảo trì.8

2. Vì sao đa luồng khó — điều kiện đua, deadlock và hành vi không xác định

Nói gọn, các vấn đề đa luồng mang tới có hai loại, bất kể ngôn ngữ.

Điều kiện đua (race condition) là bug mà kết quả phụ thuộc thứ tự nhiều luồng đến một đoạn mã. Ví dụ kinh điển là bộ đếm dùng chung: biểu thức đơn ++count tách ở mức mã máy thành ba bước — đọc, cộng, ghi lại. Nếu hai luồng vào ba bước đó cùng lúc, phép cộng của một luồng bị ghi đè và mất vì lần ghi lại của luồng kia. Kết quả đổi từ lần chạy này sang lần kia, và kết quả nào bạn nhận thì không đoán được.

Luồng BBiến dùng chung countLuồng ALuồng BBiến dùng chung countLuồng Acount = 10Hai lần tăng đã xảy ra,vậy mà count = 11 — phép cộng của Luồng A bị mấtĐọc (10)Đọc (10)Cộng cục bộ (11)Cộng cục bộ (11)Ghi lại (11)Ghi lại (11)

Hình 1: Điều kiện đua kinh điển trong đó một lần tăng trên bộ đếm dùng chung bị mất. Nếu luồng khác xen vào trong ba bước của ++count, lần ghi lại nào xảy ra sau cùng sẽ ghi đè lần kia

Deadlock là trạng thái hai luồng mỗi bên chờ khóa bên kia đang giữ, nên không ai tiến được. Luồng A giữ khóa 1 và chờ khóa 2; luồng B giữ khóa 2 và chờ khóa 1 — chỉ vậy là đủ để cả hai dừng mãi.

đang chờ chiếm khóa 2đang chờ chiếm khóa 1Luồng Ađang giữ khóa 1Luồng Bđang giữ khóa 2

Hình 2: Vòng chờ của deadlock. Khoảnh khắc các mũi tên chờ tạo thành vòng, mọi luồng trong vòng đó dừng mãi

Điều khiến cả hai khó chịu là chúng phụ thuộc thời điểm. Một xen kẽ chỉ trúng một lần trong hàng chục nghìn lần chạy trên máy phát triển có thể xảy ra mỗi ngày trên máy khách, với số nhân khác và thời điểm khác. «Không tái hiện khi gắn trình gỡ lỗi» và «biến mất khi tôi thêm ghi nhật ký» đều xảy ra vì chính việc quan sát đổi thời điểm — đó là hành vi kinh điển của bug đua. Đó chính là lý do mọi nguyên tắc trong bài này hướng một chiều: giảm những chỗ cần đồng bộ, trước khi lo đồng bộ chúng cho đúng.

2.1. Trong C++, data race trực tiếp là hành vi không xác định

Thêm nữa, C++ có một lớp sâu hơn mà ngôn ngữ khác không có. Theo chuẩn C++, nếu nhiều luồng truy cập cùng một vị trí bộ nhớ mà không đồng bộ và ít nhất một luồng ghi, đó là data race, và đó là hành vi không xác định. Chương đồng thời của C++ Core Guidelines (CP.2, «Avoid data races») nêu điều này là quy tắc tuyệt đối đầu tiên.1 Hành vi không xác định không phải câu chuyện nhẹ «bạn có thể đọc giá trị cũ hoặc mới». Trình biên dịch tối ưu trên tiền đề không có data race, nên hành vi bạn chẳng thể đoán từ mã nguồn — kiểm tra điều kiện biến mất khỏi vòng lặp, các lần ghi bị sắp lại hoặc gộp — xảy ra hoàn toàn hợp lệ. Sự cố kinh điển «cờ dừng volatile bool chỉ hỏng ở bản phát hành» là đúng kiểu trường hợp này.

2.2. RAII là nền

Một tiền đề nữa riêng của C++ là ngoại lệ và quản lý tài nguyên. C++ không có finally; thay vào đó có RAII (giải phóng tự động qua hàm hủy), và bộ công cụ đa luồng được thiết kế trên giả định bạn sẽ dùng nó. «Quản lý khóa qua vòng đời đối tượng»; «bảo đảm join luồng cũng qua vòng đời đối tượng» — đi theo quy ước đó là nền để viết C++ đa luồng an toàn.

3. Cách khởi chạy luồng — bẫy của thread, và jthread

3.1. Hàm hủy std::thread «được thiết kế để gây sự cố»

std::thread có một bẫy nổi tiếng. Nếu hàm hủy chạy trong lúc luồng vẫn joinable (chưa join cũng chưa detach), std::terminate được gọi và tiến trình chết ngay.9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← nếu ngoại lệ được ném ở đây...
    worker.join();      // ← join không bao giờ tới; hàm hủy của worker gọi terminate
}

Làm điều này an toàn với ngoại lệ đòi hỏi bảo đảm join bằng try/catch — trạng thái méo trong một ngôn ngữ RAII mà chỉ riêng luồng phải quản lý bằng tay. std::jthread của C++20 giải quyết việc này. Vì hàm hủy tự phát yêu cầu dừng rồi join, đoạn trên trở nên an toàn với ngoại lệ chỉ bằng cách chuyển sang std::jthread.2 Trên MSVC, <stop_token>jthread có từ Visual Studio 2019 16.9 trở đi.3

std::threadchưa join cũng chưa detachstd::threadđã joinstd::jthread - C++20Luồng đã được khởi chạyĐiều gì xảy rakhi scope thoát?std::terminatetiến trình chết ngayJoin an toànTự động request_stop + joinan toàn ngay cả khi ngoại lệ được ném

Hình 3: Vòng đời của đối tượng luồng và cách nó kết thúc. std::thread được quy định chết ngay nếu bạn quên join, nên từ C++20 trở đi hãy lấy jthread làm mặc định

Theo nguyên tắc, đừng dùng detach(). Một luồng đã mất mọi cách join trở thành nguyên nhân kinh điển của crash lúc tắt, đua với việc hủy biến tĩnh và heap khi tiến trình thoát.

3.2. Công cụ «trên tầng luồng» — async, future và thuật toán song song

Nguyên tắc của ấn bản .NET «đừng tự tạo luồng» ánh xạ sang các công cụ sau trong C++.

  • std::async + std::future: cho một tác vụ bất đồng bộ một lần và nhận kết quả. Tuy nhiên có một đặc điểm quan trọng: future (hoặc shared_future cuối) gắn với tác vụ khởi chạy qua std::async chặn đến khi hoàn tất nếu hàm hủy chạy trong lúc tác vụ còn chưa xong.7 Với công việc thực sự khởi chạy bằng std::launch::async, bỏ future trả về tương đương thực thi đồng bộ tại đó. Tệ hơn, nếu không chỉ định chính sách khởi chạy, triển khai được tự do chọn deferred (thực thi lười) theo mặc định; khi đó, nếu không ai gọi get() / wait(), công việc không bao giờ được thực thi và biến mất im lặng. Nếu muốn bảo đảm thực thi đồng thời, hãy chỉ định std::launch::async tường minh, và để chủ sở hữu quản lý vòng đời future.
  • PPL - Parallel Patterns Library - concurrency::parallel_for / parallel_for_each: áp dụng công việc song song trên mọi phần tử của một tập hợp. Tuy nhiên, nếu công việc trong một lần lặp quá nhỏ, chi phí fork/join ăn hết lợi ích, nên theo nguyên tắc hãy song song hóa ở vòng ngoài.10
  • Thuật toán song song C++17 - std::execution::par: trên MSVC các thuật toán chính được song song hóa (không phải tất cả).11 Lưu ý nếu ngoại lệ thoát khỏi xử lý phần tử dưới một chính sách thực thi, std::terminate được gọi. Đặt biên ngoại lệ riêng (try/catch) trong callback theo cùng tư duy với biên luồng ở mục 6.

Đường kẻ ở chỗ khác — «chờ I/O không phải thứ bạn giải bằng cách thêm luồng» — vẫn áp dụng nguyên. Với mã Windows gốc, I/O OVERLAPPED và IOCP là công cụ hứng việc đó (về cơ chế, xem «Chiều sâu I/O Windows, phần 2»).

4. Giảm trạng thái biến đổi dùng chung — tách, truyền theo giá trị, const và hàng đợi

Tranh chấp chỉ nảy sinh khi vừa có «nhiều luồng» vừa có «dữ liệu biến đổi dùng chung». Số luồng do yêu cầu quyết định, nên thiết kế có thể cắt là phần dùng chung. Cách làm thuộc ba họ — tách, làm bất biến, và chuyển dữ liệu — và đây là cách viết từng thứ trong C++.

Tách. Trong việc như tổng hợp song song, thay vì mọi luồng ghi vào một tổng dùng chung, hãy cho mỗi luồng tổng phụ cục bộ riêng rồi gộp một lần, lúc cuối. Số lần ghi vào giá trị dùng chung giảm từ «mỗi lần lặp» xuống «một lần mỗi luồng», cắt cả chi phí đồng bộ lẫn cửa sổ tranh chấp theo cấp số. Bước gộp duy nhất đó có thể làm bằng std::mutex hoặc bằng fetch_add trên std::atomic — cả hai đều được.

Truyền theo giá trị. Nếu bạn đưa dữ liệu luồng cần cho nó bằng sao chép (hoặc move) lúc khởi chạy, dữ liệu đó trở thành độc quyền của luồng, và không cần đồng bộ. Bắt lambda theo tham chiếu ([&]) rồi đụng biến đã hết vòng đời là sự cố thường gặp, nên lambda đưa cho luồng nên dùng bắt tường minh, theo sao chép hoặc move làm nguyên tắc. Dù vậy, «đã sao nên độc quyền» chỉ đúng khi giá trị là đồ thị giá trị sâu không chứa bí danh như con trỏ hoặc shared_ptr. Sao một struct chứa con trỏ thô vẫn để những gì nó trỏ tới bị dùng chung.

Chia sẻ dưới dạng const. Dữ liệu chỉ được đọc thì an toàn để đọc từ bất kỳ số luồng nào cùng lúc. Giá trị cấu hình, dữ liệu gốc, đầu vào tính toán và tương tự có thể chia sẻ không đồng bộ nếu bạn làm chúng thành chia sẻ const không bị viết lại sau khi xây (std::shared_ptr<const Config>, chẳng hạn). Một lưu ý: điều shared_ptr<const T> cấm là chỉ sự biến đổi qua đúng handle đó. Nếu một bí danh không-const còn sống ở chỗ khác, hoặc thành viên mutable bị viết lại, tranh chấp vẫn còn — nên hãy thiết kế cả điều đó, đến mức «một khi xây xong, thả tham chiếu không-const và không ai viết sau đó». Chỉ cần quyết «khi cần thay đổi, xây đối tượng mới rồi hoán, chứ không biến đổi tại chỗ» là bớt một mảnh trạng thái biến đổi bạn phải canh (về quản lý vòng đời của chính lần hoán, xem lưu ý mục 5.2).

Chuyển qua hàng đợi. Dẫn luồng dữ liệu giữa các luồng qua hàng đợi nhà sản xuất/người tiêu thụ chứ không qua biến dùng chung. Chuẩn C++ không có kiểu kênh, nên viết một hàng đợi nhỏ bằng std::mutex + std::condition_variable là mẫu đã ổn định.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // sức chứa 0 là bẫy mọi Push chờ mãi
            throw std::invalid_argument("capacity must be positive");
    }

    // Chờ đến khi còn chỗ (hoặc yêu cầu dừng) nếu đầy. false nghĩa là yêu cầu dừng.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // thức vì yêu cầu dừng
            if (st.stop_requested())                // nếu chỗ trống và dừng tới cùng lúc, ưu tiên dừng,
                return false;                       // và từ chối đẩy sau khi đã bắt đầu dừng
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // báo ngoài khóa
        return true;
    }

    // Chờ yêu cầu dừng (stop_token) hoặc một phần tử tới. nullopt khi đã dừng.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // thức vì yêu cầu dừng
            if (st.stop_requested())                // nếu phần tử và dừng tới cùng lúc, ưu tiên dừng,
                return std::nullopt;                // và không bắt đầu việc mới sau khi đã bắt đầu dừng
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // condition_variable_any, để dùng wait nhận stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

Có hai điểm thiết kế. Thứ nhất, giới hạn sức chứa và bắt phía sản xuất chờ khi đầy. Hàng đợi không trần trở thành bom hẹn giờ ở cấu hình sản xuất vượt tiêu thụ: nó «vẫn chạy», nhưng bộ nhớ cứ lớn. Để Push chặn khi đầy đóng vai trò sức ép ngược tự nhiên, truyền quá tải lên thượng nguồn một cách máy móc. Thứ hai, vì biến điều kiện chịu thức tỉnh giả (thức mà không có thông báo), luôn gọi wait với vị từ. Dạng vị từ của wait chạy hộ bạn logic «lặp đến khi điều kiện đúng» bên trong.6

5. Kỷ luật khóa — RAII và scoped_lock

Dù đã giảm trạng thái biến đổi dùng chung, thường bạn không đưa được về không. Dùng loại trừ cho phần còn dùng chung, nhưng khóa không kỷ luật chỉ giấu tranh chấp.

Trước hết, hãy nghĩ đơn vị khóa không phải «đoạn mã» mà là «dữ liệu». Gán một mutex cho mỗi tập dữ liệu biến đổi bạn muốn bảo vệ (làm thành viên private, không lộ ra ngoài), và lấy đúng mutex đó ở mọi chỗ đụng dữ liệu đó — phiên bản vỡ của bảng tương ứng này chính là phần lớn bug đua. Và điều duy nhất được phép khi đang giữ khóa là đọc và ghi dữ liệu nó bảo vệ. I/O tệp, gọi mạng và callback (gọi vào mã ngoài) khi đang giữ khóa không chỉ kéo dài thời gian giữ — chúng mở đường callee cố lấy khóa khác rồi deadlock. Chuẩn bị ngoài khóa, và trong khóa chỉ hoán là hình dạng cơ bản.

5.1. Viết tay lock()/unlock() bị cấm

Mã gọi trực tiếp lock() / unlock() của std::mutex kết thúc bằng việc không giải phóng khóa khi có ngoại lệ hoặc return sớm. Luôn để việc chiếm và giải phóng khóa cho bao RAII.

Bao Dùng khi
std::lock_guard Giữ một mutex đúng bằng thời gian một scope — hình dạng cơ bản nhất
std::scoped_lock (C++17) Chiếm nhiều mutex cùng lúc. Giải quyết vấn đề thứ tự bằng thuật toán tránh deadlock4
std::unique_lock Khi muốn mở rồi khóa lại giữa chừng, hoặc cần đưa cho condition_variable::wait

Khi có từ hai khóa trở lên, để thứ tự chiếm đổi tùy luồng là mẫu deadlock kinh điển (vòng chờ ở Hình 2 sinh đúng cách này). Cách sửa là đặt luật «mọi luồng chiếm khóa cùng một thứ tự», nhưng khi bạn chiếm chúng cùng lúc, C++ có câu trả lời tốt hơn: đưa vài mutex cùng lúc cho std::scoped_lockthư viện bảo đảm thứ tự chiếm không deadlock cho bạn.4 Trong tình huống như chuyển khoản giữa hai đối tượng khi bạn muốn «cả hai đều bị khóa», đừng bao giờ lấy từng cái — luôn lấy cùng lúc.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // không làm gì với cùng một tài khoản (xem chú dưới)
    std::scoped_lock lock(from.mtx, to.mtx);   // cả hai cùng lúc; thư viện giải quyết thứ tự
    from.balance -= amount;
    to.balance   += amount;
}

Kiểm tra đồng nhất ở đầu không phải trang trí. Nếu cùng một Account được truyền vừa from vừa to, bạn kết thúc bằng việc đưa cùng một mutex không đệ quy cho scoped_lock hai lần, gây treo hoặc hành vi không xác định. Luôn gắn loại trừ cùng đối tượng cho mọi hàm «khóa cả hai».

Với dữ liệu «đọc nhiều, ghi hiếm», bạn có thể dùng std::shared_mutex (C++17) như khóa đọc/ghi.12 recursive_mutex là kiểu thiết kế để «cùng luồng chiếm lại không vỡ», nhưng thiết kế cần chiếm đệ quy thường là dấu hiệu biên trách nhiệm của khóa đã mờ — hãy cân nhắc xem lại cấu trúc trước.

5.2. Vai trò đúng của atomic

std::atomic cung cấp thao tác nguyên tử trên một biến, cộng thứ tự dựa trên memory_order.5 Nó đáng chỗ trong cùng tình huống với Interlocked ở ấn bản .NET: cập nhật một biến đơn, như bộ đếm hoặc cờ. Nó không giữ nhiều biến nhất quán cùng lúc, nên với việc đó hãy quay về std::mutex.

Hoán con trỏ thô (std::atomic<T*>) có bẫy riêng. Dù chính lần hoán là nguyên tử, không ai bảo vệ vòng đời đối tượng cũ sau khi đã bị thay. Nếu người đọc tải con trỏ cũ ngay trước khi người viết hoán và delete, bạn truy cập bộ nhớ đã giải phóng. Nếu muốn thiết kế «hoán và chia sẻ đối tượng bất biến» trong C++, hãy chọn phương tiện đi kèm quản lý vòng đời — hoán std::shared_ptr<const T> được khóa bảo vệ, hoặc std::atomic<std::shared_ptr<T>> của C++20.

Và nhắc lại: volatile không phải công cụ đồng bộ luồng. Lập trình không khóa khi tự chỉ định memory_order là lãnh địa chuyên gia, đòi vừa lý do chính đáng để nới từ mặc định (seq_cst) vừa cách kiểm chứng bạn đã làm đúng. Trong ứng dụng nghiệp vụ, hoặc dùng mặc định hoặc viết bằng mutex ngay từ đầu.

6. Thiết kế cách dừng — stop_token và dừng hợp tác

Câu hỏi đầu tiên khi rà thiết kế đa luồng là «cái này dừng thế nào?». Và C++ không có cách dừng an toàn một luồng từ ngoài (Win32 TerminateThread nguy hiểm thế nào được nói kỹ trong Ấn bản C). Vậy cách một luồng dừng phải được xây bằng công cụ C++ quanh dừng hợp tác — phía dừng chỉ phát yêu cầu; chính luồng quyết định khi nào và cách kết thúc, tại điểm để mọi thứ gọn; và hoàn tất join mới được tính là «đã dừng».

Trong C++20, std::jthread có sẵn cơ chế dừng. Gọi request_stop() dựng yêu cầu dừng trên std::stop_token mà hàm luồng đã nhận, và vòng lặp thăm dò nó. wait của condition_variable_any nhận trực tiếp stop_token, nên một «luồng đang chờ việc tới» cũng có thể được đánh thức ngay bởi yêu cầu dừng (BlockingQueue::Pop ở mục 4 đúng hình này).

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // Từ chối Start lần hai khi đang chạy.
            throw std::logic_error("already running"); // Nếu gán thay vì từ chối, một luồng mới
                                                       // sẽ bắt đầu chạy, và trong lúc chờ
                                                       // luồng cũ dừng, hai worker
                                                       // sẽ chạy cạnh nhau
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // cũng thức khi có yêu cầu dừng
                        try {
                            Process(*item, st);          // truyền st vào cả việc có thể chặn bên trong
                        } catch (...) {
                            ReportError(std::current_exception());  // ghi một lần thất bại rồi tiếp tục
                        }
                    }
                }
            } catch (...) {
                // Phòng tuyến cuối ở biên luồng (cũng bắt thất bại
                // trong Pop hoặc move). Nếu ngoại lệ thoát khỏi đây, std::terminate
                // hạ cả tiến trình, nên bảo đảm chính ReportError không bao giờ ném
                ReportError(std::current_exception());
            }
        });
    }
    // Không cần Stop tường minh:
    // hàm hủy Worker -> hàm hủy jthread -> request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // giới hạn sức chứa (mục 4)
    std::jthread thread_;
};
yêu cầu dừngPhía dừng- hàm hủy jthread, hoặc request_stopstop_tokenVòng tính:thăm dò stop_requested()Luồng đang chờ:condition_variable_any::wait(lock, st, pred)thức ngayTự dọn rồi returnjoin hoàn tất rendezvouschỉ lúc này mới gọi là đã dừng

Hình 4: Dừng hợp tác C++20. Phía dừng chỉ phát yêu cầu; chính luồng quyết định cách kết thúc; hoàn tất join mới được tính là đã dừng

Một điểm nữa: try/catch trong worker không thể bỏ. Điều jthread làm an toàn với ngoại lệ là join, và chỉ join — nếu ngoại lệ thoát khỏi hàm luồng, std::terminate hạ tiến trình, y như std::thread. Hãy quyết tường minh, tại biên luồng, cách xử lý thất bại của một việc (ghi rồi tiếp tục, hoặc báo chủ qua kênh lỗi).

Cùng lý do, hãy để ý Process cũng được truyền stop_token. Nếu xử lý một việc chặn bên trong (chờ mạng, tính toán dài, v.v.) và điểm đó không quan sát được yêu cầu dừng, join ngầm của hàm hủy sẽ chờ mãi một hạng mục đó xong. Dừng hợp tác chỉ đứng vững khi token đã tới mọi chỗ đang chờ. Nếu việc gồm lời gọi ngoài không ngắt được, hãy gắn timeout và đặt trần thời gian một hạng mục được phép chạy.

Ở môi trường trước C++17, bạn tự xây cùng hình bằng cờ dừng std::atomic<bool> cộng notify_all của condition_variable. Điểm then chốt ở đây là gập kiểm tra cờ dừng vào vị từ của biến điều kiện — nếu chỉ dựng cờ rồi quên báo, luồng đang chờ sẽ không bao giờ thức.

7. Quan ngại riêng Windows — biên với API Win32

7.1. Chọn giữa thư viện chuẩn và đối tượng đồng bộ Win32

Tài liệu Microsoft khuyến nghị std::mutex / std::shared_mutex cho mã C++ ưu tiên tính di động, và đặt chỗ của đối tượng đồng bộ Win32 là «khi cần API chờ Win32» và «đồng bộ xuyên tiến trình».8

Tình huống Lựa chọn
Loại trừ trong tiến trình thông thường std::mutex + RAII (mặc định)
Đọc nhiều, ghi hiếm std::shared_mutex
Chờ vài đối tượng cùng lúc bằng WaitForMultipleObjects Đối tượng kernel Win32 như event và mutex
Loại trừ / thông báo xuyên tiến trình Mutex, event, semaphore có tên
Khóa trong tiến trình dùng trực tiếp API Win32 Khóa SRW (CRITICAL_SECTION chỉ khi cần đệ quy)8

Về thiết kế cụ thể loại trừ truy cập bộ nhớ dùng chung xuyên tiến trình, xem «Cạm bẫy bộ nhớ dùng chung và thực hành tốt nhất».

7.2. Đừng đụng luồng trong DllMain

Một ràng buộc nghiêm khi viết DLL là loader lock. DllMain được gọi trong lúc loader lock đang được giữ, nên các thao tác bên trong như đồng bộ với luồng khác, chờ một luồng kết thúc, hoặc gọi LoadLibrary gây deadlock hoặc hành vi không đoán được. Hãy chuyển mọi khởi tạo khởi chạy hoặc join luồng ra ngoài DllMain, vào một hàm khởi tạo tường minh.13

7.3. Luồng UI và apartment COM

Ứng dụng máy tính để bàn Windows có ràng buộc mạnh áp dụng bất kể ngôn ngữ: chỉ luồng đã tạo cửa sổ hoặc điều khiển — luồng UI — mới được đụng nó. Windows giao thông điệp cửa sổ vào hàng đợi thông điệp của luồng đã tạo cửa sổ đó, nên tạo và thao tác UI phải tập trung trên luồng đó. Khi muốn cập nhật màn hình từ luồng worker, đừng đụng trực tiếp — nhờ luồng UI bằng PostMessage (bất đồng bộ), và xử lý trong thủ tục cửa sổ phía luồng UI. Gọi dạng đồng bộ, SendMessage, trong lúc luồng UI đang chờ worker đó xong gây deadlock mỗi bên chờ bên kia, nên hãy lấy dạng bất đồng bộ làm mặc định cho thông báo từ worker. STA/MTA, khi có COM, được trình bày trong «Nền tảng COM STA/MTA - Mô hình phân luồng và cách tránh treo». Cũng lưu ý trong mã C++/CLI biên dịch với /clr, các header luồng chuẩn như <thread><mutex> bị chặn.14

8. Kiểm chứng và gỡ lỗi — chuẩn bị trên giả định sẽ không tái hiện

Bạn không thể dựa vào kiểm thử để tìm bug đua, vì một kiểm thử thường tính một lần chạy «tình cờ không đua» là đậu. Hãy nghĩ sự chuẩn bị thành ba lớp.

Tuyến phòng thủ đầu là đúng các nguyên tắc thiết kế đã nêu. Khi rà, xác nhận bằng bảng: dữ liệu biến đổi nào được dùng chung, mutex nào bảo vệ từng mảnh, thứ tự chiếm nhiều khóa có duy nhất không (hay chúng được lấy cùng lúc bằng scoped_lock), và đường dừng ở đâu. Thiết kế mà bạn không viết được bảng này thì chưa xong, dù hiện đang chạy tốt.

Thứ hai, hãy làm trạng thái bất thường quan sát được thay vì giấu. Gắn timeout bằng try_lock_for của timed_mutex hoặc wait_for của condition_variable cho mọi khóa lẽ ra không được thất bại khi chiếm, và ghi timeout như bất thường — điều đó biến treo vĩnh viễn thành thất bại phát hiện được. Luôn ghi ngoại lệ bắt ở try/catch biên luồng (mục 6). Khi treo hoặc crash xảy ra ngoài hiện trường, bắt dump, kiểm tra ngăn xếp mọi luồng, và xem các lần chờ khóa có tạo vòng không. Thiết lập dump và ghi nhật ký được trình bày trong «Thiết kế ghi nhật ký và bắt dump khi ứng dụng Windows crash».

Thứ ba, lay mọi thứ dưới tải. Chạy lâu với độ song song nhiều hơn số nhân, xáo thứ tự xử lý, và bơm trễ nhân tạo là kỹ thuật stress-test thực tế giúp dễ «trúng độc đắc» đua trên máy phát triển. Bug biến mất ở bản gỡ lỗi thường tái hiện sẵn ở bản phát hành đã tối ưu dưới tải nặng.

9. Tóm tắt — danh sách kiểm C++

Chồng các kiểm riêng C++ lên các nguyên tắc chung mọi ngôn ngữ: đừng tự tạo luồng, tối thiểu hóa trạng thái biến đổi dùng chung, tương ứng một-một giữa khóa và dữ liệu, và dừng hợp tác.

  1. std::thread có đang bị dùng trần không (có thể là jthread? join có được bảo đảm cả trên đường ngoại lệ?)
  2. detach() có đang không được dùng?
  3. Việc bắt lambda có tường minh, và biến bắt theo tham chiếu có sống lâu hơn luồng?
  4. Bạn có nói chắc không có dù chỉ một truy cập biến đổi dùng chung không đồng bộ (= hành vi không xác định) ở đâu?
  5. Có đang không viết tay lock() / unlock(), và nhiều khóa được lấy cùng lúc bằng scoped_lock?
  6. Mọi condition_variable::wait có dùng với vị từ?
  7. volatile có đang không dùng cho cờ dùng chung (là std::atomic thay thế)?
  8. Đường dừng có được thiết kế quanh stop_token (hoặc cờ atomic cộng thông báo), với hoàn tất join xác nhận rendezvous?
  9. future từ std::async có đang không bị bỏ?
  10. DllMain có sạch khỏi khởi chạy, đồng bộ, hoặc join luồng?

C++ đa luồng là việc đi sát mép vực hành vi không xác định, nhưng đảo lại nghĩa là chỉ cần đi thật với RAII và quy ước thư viện chuẩn đã đặt khoảng cách thực với mép đó. jthread, scoped_lock, wait dạng vị từ, atomic — chọn đúng mặc định trong các công cụ này, ở C++, chính là thực hành các nguyên tắc thiết kế.

Bài viết liên quan

Lĩnh vực tư vấn liên quan

KomuraSoft LLC đảm nhận rà thiết kế đa luồng cho ứng dụng và DLL C++, điều tra nguyên nhân gốc (phân tích dump) các lỗi điều kiện đua như «thỉnh thoảng crash» hoặc «chỉ lệch ở bản phát hành», và tư vấn chuyển mã luồng cũ sang C++ hiện đại.

Liên kết tham khảo

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. Về việc CP.1 (hãy giả định mã của bạn sẽ chạy như một phần của chương trình đa luồng) và CP.2 (tránh data race) được nêu làm quy tắc mở đầu chương đồng thời và song song; về việc không còn bảo đảm nào đứng sau khi đã có data race; và về việc các quy tắc thiết kế cho mã đồng thời — phạm vi giữ khóa, việc dùng RAII, v.v. — được hệ thống hóa ở đó.  2

  2. cppreference.com, std::jthread. Về việc jthread của C++20 khác std::thread ở chỗ hàm hủy tự gọi request_stop() rồi join; về việc nhận std::stop_token làm đối số đầu của hàm luồng; và về việc điều này bảo đảm cả join lẫn yêu cầu dừng ngay cả khi ngoại lệ được ném.  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Về việc P0660R10 (<stop_token> và jthread) và P1135R6 (thư viện đồng bộ C++20) được hỗ trợ từ Visual Studio 2019 16.9; và về trạng thái hỗ trợ theo phiên bản của các tính năng thư viện chuẩn C++.  2 3

  4. Microsoft Learn, scoped_lock Class. Về việc scoped_lock của C++17 chiếm một hoặc nhiều mutex lúc xây và giải phóng trong hàm hủy; về việc nhiều mutex, khi được đưa cùng lúc, được chiếm bằng thuật toán tránh deadlock tương đương std::lock; về việc giải phóng đáng tin cậy ngay cả khi ngoại lệ được ném; và về việc lock_guard/unique_lock cũng là lựa chọn khi chỉ có một mutex.  2 3

  5. Microsoft Learn, <atomic>. Về việc thao tác nguyên tử là không tách được, nên các luồng khác chỉ quan sát được trạng thái trước hoặc sau thao tác; về việc thiết lập, dựa trên đối số memory_order, yêu cầu thứ tự về tính thấy được của các thao tác nguyên tử khác, và chặn tối ưu trình biên dịch sẽ vi phạm chúng; về việc atomic_flag luôn không khóa; và về việc header này bị chặn dưới /clr:pure.  2

  6. Microsoft Learn, <condition_variable>. Về việc chờ trên biến điều kiện đòi mutex, với khóa được nhả trong suốt thời gian chờ; về việc thức tỉnh giả tồn tại — thức mà không có thông báo — nên phía chờ nên kiểm tra lại điều kiện khi trở về, và dạng vị từ wait(lock, pred) thực hiện vòng lặp đó thay bạn; và về việc condition_variable_any kết hợp được với mọi kiểu mutex.  2

  7. Microsoft Learn, <future>. Về việc hàm hủy của future và shared_future theo nguyên tắc không chặn, với ngoại lệ duy nhất là future (hoặc shared_future cuối) gắn với tác vụ khởi chạy bằng std::async sẽ chặn đến khi trạng thái dùng chung sẵn sàng nếu hàm hủy chạy trong lúc tác vụ còn chưa xong — hành vi được chuẩn ghi chú tường minh.  2

  8. Microsoft Learn, About Synchronization. Về hướng dẫn chọn nguyên thủy đồng bộ Win32: std::mutex / std::shared_mutex và RAII được khuyến nghị cho mã C++ ưu tiên tính di động; đối tượng đồng bộ Win32 được dùng khi cần API chờ Win32 hoặc đồng bộ xuyên tiến trình; mặc định cho mã trong tiến trình mới là khóa SRW, với CRITICAL_SECTION dành cho khi cần chiếm đệ quy; và dùng Mutex cho đồng bộ trong tiến trình là «sai lầm phổ biến» vì luôn kéo theo chuyển tiếp kernel.  2 3

  9. cppreference.com, std::thread::~thread. Về việc hàm hủy của std::thread gọi std::terminate nếu được gọi trong lúc luồng vẫn joinable (chưa join cũng chưa detach) — tức là quyết định join hay detach phải được chốt trước khi đối tượng luồng bị hủy, không ngoại lệ. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. Về việc song song lý tưởng nên được diễn đạt ở mức càng cao càng tốt (vòng ngoài); về việc chi phí lập lịch fork/join có thể vượt lợi ích thực thi song song ở các vòng song song mà công việc mỗi lần lặp nhỏ hoặc lệch; và về việc xu hướng đó mạnh hơn khi số bộ xử lý tăng. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Về việc thư viện thuật toán song song C++17 là đầy đủ, trong khi «đầy đủ» không có nghĩa mọi thuật toán đều được song song hóa trong mọi trường hợp; về chính sách triển khai song song hóa các thuật toán quan trọng nhất đồng thời vẫn cung cấp chữ ký chính sách thực thi cho những cái không được. 

  12. Microsoft Learn, C++ standard library header files. Về việc các header chuẩn liên quan đa luồng được sắp là <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20), và <thread> (C++11). 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. Về việc DllMain được gọi trong lúc loader lock đang được giữ, đặt ràng buộc nghiêm về API nào nó được gọi; về việc đồng bộ với luồng khác trong DllMain có thể deadlock; về việc gọi LoadLibrary hoặc chờ một luồng kết thúc là hành động điển hình bị cấm; về việc khởi tạo lý tưởng nên được hoãn càng xa càng tốt và chuyển ra ngoài DllMain; và về việc định nghĩa thứ bậc khóa với loader lock ở trên cùng. 

  14. Microsoft Learn, <thread>. Về việc header <thread> định nghĩa lớp thread và các hàm trợ giúp như sleep_for; về việc header này bị chặn trong mã biên dịch với /clr; và về việc macro STDCPP_THREADS cho phép xác định hỗ trợ luồng có hay không. 

Các bài viết gần đây có cùng thẻ để tìm hiểu sâu hơn những chủ đề lân cận.

Các trang này đặt chủ đề trong bối cảnh rộng hơn của dịch vụ và quyết định.

Bài viết liên quan trực tiếp đến các dịch vụ sau.

Câu hỏi thường gặp

Các câu hỏi thường gặp khi tư vấn về chủ đề của bài viết.

Nên chọn thế nào giữa std::mutex và CRITICAL_SECTION / khóa SRW của Win32?
Với mã C++ thông thường ưu tiên tính di động, std::mutex / std::shared_mutex cùng các bao RAII (lock_guard / scoped_lock) là lựa chọn đầu tiên. Dùng đối tượng đồng bộ Win32 khi cần kết hợp chúng với API chờ Win32 như WaitForMultipleObjects, hoặc khi cần đồng bộ xuyên tiến trình qua đối tượng có tên. Nếu dùng API Win32 trực tiếp trong một tiến trình, mặc định cho mã mới là khóa SRW, và chỉ dùng CRITICAL_SECTION khi cùng một luồng cần chiếm lại theo kiểu đệ quy. Dùng Mutex Win32 cho loại trừ trong tiến trình là sai lầm kinh điển, vì luôn kéo theo chuyển tiếp kernel và vì thế chậm.
Dùng detach() của std::thread có được không?
Theo nguyên tắc, hãy tránh. Một luồng đã detach mất mọi cách để được join, và bạn mất kiểm soát liệu nó còn chạy khi tiến trình thoát. Đó là sự cố kinh điển: luồng đã detach tiếp tục chạy sau khi biến tĩnh hoặc heap đã bị hủy, rồi gây crash lúc tắt. Có thể chờ luồng kết thúc là yêu cầu cơ bản của thiết kế luồng, nên hãy dùng jthread (tự join), hoặc, nếu dùng thread, cấu trúc mã để luôn join trước khi scope kết thúc. detach chỉ được phép trong tình huống hẹp: luồng có thể chung số phận với tiến trình và bạn bảo đảm nó không đụng trạng thái dùng chung chút nào.
volatile có dùng được cho đồng bộ giữa các luồng trong C++ không?
Không. volatile của C++ là bộ định tính cho các lần đọc và ghi mà bạn không muốn trình biên dịch tối ưu mất — ví dụ I/O ánh xạ bộ nhớ — và không bảo đảm tính thấy được hay thứ tự giữa các luồng. Nếu nhiều luồng truy cập cùng một biến mà không đồng bộ, đó là data race, và đó là hành vi không xác định. Dùng std::atomic cho cờ và bộ đếm dùng chung giữa các luồng, và std::mutex khi cần bảo vệ nhiều biến cùng lúc. std::atomic vừa cho tính nguyên tử của thao tác vừa cho thứ tự dựa trên memory_order.
std::async trông tiện, nhưng có bẫy nào không?
Bẫy lớn nhất là hàm hủy của future. future (hoặc shared_future cuối cùng) gắn với tác vụ khởi chạy bằng std::async sẽ chặn đến khi hoàn tất nếu hàm hủy chạy trong lúc tác vụ còn chưa xong. Nếu bạn bỏ future trả về mà không giữ, điều đó tương đương thực thi đồng bộ ngay tại chỗ — sự cố khi bạn định đi bất đồng bộ nhưng lại thành tuần tự. Ngoài ra, khi không chỉ định chính sách khởi chạy, việc công việc có thực sự chạy trên luồng riêng hay không do triển khai quyết định. Nếu dùng, hãy quản lý vòng đời future một cách tường minh, và chỉ định std::launch::async ở mọi nơi cần bảo đảm thực thi đồng thời.

Hồ sơ tác giả

Trang giới thiệu tác giả bài viết.

Go Komura

Đại diện của KomuraSoft LLC

Chuyên về phát triển phần mềm Windows, tư vấn kỹ thuật và điều tra lỗi, đặc biệt trong các dự án có hệ thống hiện hữu và lỗi khó tái hiện.

Liên kết công khai

Quay lại blog