Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản9 phút đọc

Mất cập nhật qua sáu bước xen kẽ

Lần theo hai phép tăng làm mất cập nhật, bảo vệ toàn bộ vùng tới hạn và tái hiện lỗi logic bằng C++ atomic có hành vi xác định.

Mô hình ba cụm khối nhỏ nối với nhau bằng các rãnh và những viên bi gỗ

Một cập nhật bị mất khi nhiều thao tác cùng đọc giá trị cũ, rồi ghi kết quả đè lên phần đóng góp của nhau. Nếu bạn mới học lập trình đa luồng, bộ đếm khởi đầu bằng không là ví dụ đủ nhỏ để lần theo: hai lần tăng phải cho kết quả hai, nhưng chuỗi đọc, sửa và ghi không được bảo vệ có thể chỉ để lại một.

Trình tự dưới đây là mô hình xen kẽ các bước. Nó không dự đoán một chương trình C++ không đồng bộ chắc chắn sẽ in gì. Trong C++, các truy cập xung đột vào số nguyên dùng chung thông thường, nếu thiếu đồng bộ phù hợp, có thể tạo data race, tức tranh chấp dữ liệu dẫn tới hành vi không xác định. Quy tắc data race trong bản thảo chuẩn C++ xác định ranh giới này. Phải xét mô hình bộ nhớ của ngôn ngữ trước khi chỉ lập luận bằng lịch chạy lệnh máy.

Chương về khóa của OSTEP dùng vùng tới hạn để giải thích cách làm một thao tác có vẻ không thể bị chia nhỏ đối với những luồng đang cạnh tranh. Đơn vị cần bảo vệ là toàn bộ bước chuyển trạng thái giữ đúng ý nghĩa của bộ đếm.

Tách một lần tăng thành các bước

Trong mô hình, luồng đọc bộ đếm vào thanh ghi riêng, cộng một vào giá trị cục bộ rồi ghi kết quả về. A và B có thanh ghi riêng. Bộ đếm là trạng thái dùng chung duy nhất mà cả hai cập nhật.

BướcLuồng ALuồng BBộ đếm dùng chung
1Đọc 0Chờ0
2ChờĐọc 00
3Tính 1Chờ0
4ChờTính 10
5Ghi 1Chờ1
6ChờGhi 11

Mọi phép cộng đều đúng về số học. Lỗi xuất hiện vì cả hai thao tác dựa trên cùng một trạng thái cũ. Lần ghi cuối của B không mang thông tin rằng A đã đóng góp một lần tăng. Sửa phép cộng cục bộ không lấy lại được phần đóng góp bị mất.

A và B cùng đọc không rồi lần lượt ghi một; bộ đếm cuối cùng chỉ bằng mộtMở ảnh kích thước đầy đủ ↗

So sánh với lịch chạy tuần tự: A đọc không và ghi một trước khi B đọc; B sau đó đọc một và ghi hai. Đặc tả của thao tác là mỗi lần tăng hoàn tất đóng góp đúng một đơn vị. Đặc tả ấy cho biết cơ chế đồng bộ cần giữ điều gì.

Bảo vệ toàn bộ bước chuyển trạng thái

Mutex ngăn luồng khác vào vùng tăng bộ đếm trong lúc một luồng đang giữ khóa. Vùng được bảo vệ phải gồm cả đọc, cộng và ghi. Chỉ khóa ở phép gán cuối vẫn cho phép hai luồng tính từ cùng một giá trị cũ trước khi lần lượt lấy khóa để ghi.

Đoạn C++ sau giả định mọi truy cập vào count đều tuân thủ cùng một mutex, đồng thời các đối tượng vẫn tồn tại trong suốt thời gian có thể được gọi:

std::mutex count_mutex;
int count = 0;

void increment() {
    std::lock_guard<std::mutex> guard(count_mutex);
    ++count;
}

Đối tượng guard tự nhả mutex khi ra khỏi phạm vi. Vòng đời tài nguyên vẫn là yêu cầu riêng: hủy mutex trong khi luồng khác còn có thể gọi hàm là một lỗi khác. Khóa không tự quản lý quyền sở hữu của đối tượng chứa nó.

Luồng đọc cũng cần quy tắc tương thích. Một luồng đọc count thông thường mà không đồng bộ vẫn có thể xung đột với luồng ghi đang giữ khóa. Bảo vệ mọi luồng ghi nhưng bỏ qua luồng đọc không tự tạo ra chương trình C++ hợp lệ. Ranh giới đồng bộ áp dụng cho các truy cập, không chỉ những hàm được đặt tên là cập nhật.

Thao tác nguyên tử có thể biểu đạt đúng hợp đồng nhỏ hơn

Với bộ đếm độc lập, một thao tác đọc-sửa-ghi nguyên tử có thể thực hiện trực tiếp lần tăng. fetch_add cộng một giá trị như một thao tác nguyên tử trong thứ tự sửa đổi của chính đối tượng atomic đó. Nó tránh lỗi tách tải rồi lưu, ngay cả khi dùng thứ tự relaxed phù hợp với một bộ đếm chỉ mang ý nghĩa số lượng.

Một lần tải atomic rồi một lần lưu atomic riêng biệt có ý nghĩa khác. Từng truy cập có thể nguyên tử, nhưng lần tăng gồm cả hai vẫn mất cập nhật. Trình tự trên vẫn xảy ra nếu cả hai luồng tải không trước khi bên nào lưu một. Cần thao tác đọc-sửa-ghi nguyên tử hoặc một giao thức đồng bộ bao trùm cả thao tác.

Lựa chọn thứ tự bộ nhớ còn trả lời câu hỏi khác: bộ đếm có dùng để công bố dữ liệu ở nơi khác hay không. Bộ đếm thống kê chỉ cần tổng số có thể không cần những bảo đảm thứ tự của một cờ báo rằng dữ liệu đã khởi tạo xong. Tách hai hợp đồng ấy trước khi chọn chế độ bộ nhớ.

Thêm trường dữ liệu thì phải giữ quan hệ giữa chúng

Giả sử ngoài bộ đếm còn có tổng giá trị, và người đọc cần cặp tương ứng để tính trung bình. Cập nhật hai atomic độc lập có thể để lộ số lượng mới đi cùng tổng cũ. Từng trường không có data race nhưng cặp dữ liệu vẫn vi phạm yêu cầu về ảnh chụp trạng thái của người đọc.

Một mutex bao quanh cả hai cập nhật và thao tác đọc cặp có thể giữ quan hệ ấy. Có thể dùng thiết kế khác, nhưng phải nêu bất biến và chứng minh người đọc nhận được một cặp chấp nhận được. Chọn kiểu atomic riêng cho từng trường chưa đủ để đặc tả thao tác gồm nhiều trường.

Kiểm thử có thể dùng hàng rào đồng bộ để buộc các bước đọc và ghi của mô hình đi theo lịch mất cập nhật. Điều đó chứng minh mô hình có lịch chạy xấu. Kiểm thử tải nặng của chương trình thật có thể bộc lộ lỗi, nhưng nhiều lần thu được tổng đúng không chứng minh lỗi không tồn tại. Tối ưu hóa của trình biên dịch và phần cứng khác có thể làm thay đổi kết quả quan sát được.

Khi rà soát mã đồng thời, hãy viết thao tác tuần tự trước. Nêu trạng thái nó đọc, trạng thái nó đổi và bất biến phải giữ sau khi hoàn tất. Sau đó kiểm tra mọi truy cập đồng thời có tham gia giao thức bảo vệ bước chuyển ấy hay không. Với ví dụ này, đơn vị còn thiếu là một lần tăng hoàn chỉnh.

Tái hiện lỗi logic mà không tạo data race trong C++

Thí nghiệm hữu ích cần có hành vi được ngôn ngữ xác định. Chương trình C++20 hoàn chỉnh dưới đây dùng bộ đếm atomic cho mọi truy cập dùng chung. Một barrier có hai bên tham gia giữ mỗi worker lại sau lần tải, cho đến khi cả hai đã tải xong. Chỉ sau đó chúng mới được lưu. Biên dịch bằng triển khai C++20 có std::barrier, chẳng hạn g++ -std=c++20 -pthread demo.cpp -o demo, rồi chạy tệp thực thi.

#include <atomic>
#include <barrier>
#include <iostream>
#include <thread>

int main() {
    std::atomic<int> count{0};
    std::barrier both_loaded{2};
    auto split_increment = [&] {
        int old = count.load(std::memory_order_relaxed);
        both_loaded.arrive_and_wait();
        count.store(old + 1, std::memory_order_relaxed);
    };
    std::thread a(split_increment), b(split_increment);
    a.join();
    b.join();
    if (count.load() != 1) return 1;
    std::cout << "split load/store: " << count.load() << '\n';

    count.store(0);
    auto whole_increment = [&] {
        count.fetch_add(1, std::memory_order_relaxed);
    };
    std::thread c(whole_increment), d(whole_increment);
    c.join();
    d.join();
    if (count.load() != 2) return 2;
    std::cout << "fetch_add: " << count.load() << '\n';
}

Hai dòng dự kiến là split load/store: 1 và fetch_add: 2. Giao thức buộc kết quả đầu xảy ra; nó không dùng lệnh ngủ để chỉ làm một khoảng thời gian thuận lợi dễ xuất hiện hơn. Cả hai lần tải kết thúc trước khi bất kỳ lần lưu nào bắt đầu, nên mỗi worker đều lưu một. Đặc tả barrier của C++ mô tả quy tắc hoàn tất pha này. Sau khi join cả hai worker, luồng chính có thể kiểm tra pha đã xong trước khi tái sử dụng bộ đếm.

Tải và lưu atomic tách rời đều ghi một; hai fetch_add đưa bộ đếm lần lượt từ không lên một rồi haiMở ảnh kích thước đầy đủ ↗

Pha thứ hai dùng thao tác đọc-sửa-ghi nguyên tử khớp với đặc tả tuần tự của bộ đếm. Hai phép cộng chiếm hai vị trí khác nhau trong thứ tự sửa đổi. Không quan trọng worker nào đứng trước: tổng cuối vẫn là hai. Thứ tự relaxed không làm mất tính nguyên tử của phép cộng, nhưng cũng không cung cấp một giao thức tổng quát để công bố dữ liệu không liên quan.

Đổi những lần tải và lưu ở pha đầu sang thứ tự sequentially consistent vẫn không sửa được lỗi logic. Hoàn toàn có thể tồn tại thứ tự toàn cục gồm tải A, tải B, lưu A, lưu B, và thứ tự ấy vẫn kết thúc ở một. Sắp xếp chặt hơn các thao tác tách rời không đồng nghĩa với gộp chúng thành một thao tác. Phân biệt này hữu ích khi công cụ không báo data race nhưng ứng dụng thỉnh thoảng làm mất cập nhật ở mức nghiệp vụ.

Thử đổi giá trị đầu thành mười và cho mỗi worker cộng ba. Hãy dự đoán mười ba cho giao thức tách bước và mười sáu cho fetch_add, rồi sửa các kiểm tra. Chỉ giữ barrier trong phiên bản tách bước: đặt nó giữa đọc và ghi một số nguyên thông thường chưa được đồng bộ không làm mọi truy cập tự nhiên hết data race. Bài tập kiểm tra ranh giới thao tác, không phải cách lách quy tắc tranh chấp dữ liệu của ngôn ngữ.

Nguồn tham khảo & đọc thêm

  1. OSTEP: Locks
  2. C++ working draft: data races
  3. C++ working draft: atomic integral operations
  4. C++ working draft: barrier
← Về trang bài viết

Hình minh họa

100%