Trong khi tôi đang làm việc thông qua một video hướng dẫn có thể tải xuống trực tuyến để phát triển 3D Graphics & Game Engine làm việc với OpenGL hiện đại. Chúng tôi đã sử dụng volatile
trong một trong các lớp học của mình. Trang web hướng dẫn có thể được tìm thấy ở đây và video làm việc với volatile
từ khóa được tìm thấy trong Shader Engine
video loạt bài 98. Những tác phẩm này không phải của riêng tôi nhưng đã được công nhận Marek A. Krzeminski, MASc
và đây là một đoạn trích từ trang tải xuống video.
Và nếu bạn đã đăng ký trang web của anh ấy và có quyền truy cập vào video của anh ấy trong video này, anh ấy sẽ tham khảo bài viết này liên quan đến việc sử dụng Volatile
với multithreading
lập trình.
dễ bay hơi: Người bạn tốt nhất của lập trình viên đa luồng
Bởi Andrei Alexandrescu, ngày 01 tháng 2 năm 2001
Từ khóa dễ bay hơi được tạo ra để ngăn chặn các tối ưu hóa trình biên dịch có thể làm cho mã không chính xác khi có các sự kiện không đồng bộ nhất định.
Tôi không muốn làm hỏng tâm trạng của bạn, nhưng cột này đề cập đến chủ đề đáng sợ của lập trình đa luồng. Nếu - như phần trước của Generic đã nói - lập trình an toàn ngoại lệ là khó, thì đó là trò chơi của trẻ con so với lập trình đa luồng.
Nói chung, các chương trình sử dụng nhiều luồng là khó viết, chứng minh là đúng, gỡ lỗi, duy trì và chế ngự. Các chương trình đa luồng không chính xác có thể chạy trong nhiều năm mà không gặp trục trặc, chỉ để chạy bất ngờ vì một số điều kiện thời gian quan trọng đã được đáp ứng.
Không cần phải nói, một lập trình viên viết mã đa luồng cần tất cả sự trợ giúp mà cô ấy có thể nhận được. Cột này tập trung vào các điều kiện chủng tộc - một nguồn rắc rối phổ biến trong các chương trình đa luồng - và cung cấp cho bạn thông tin chi tiết và công cụ về cách tránh chúng và đáng kinh ngạc là trình biên dịch đã làm việc chăm chỉ để giúp bạn với điều đó.
Chỉ là một từ khóa nhỏ
Mặc dù cả hai Tiêu chuẩn C và C ++ đều im lặng rõ ràng khi nói đến các luồng, nhưng chúng thực sự nhượng bộ một chút đối với đa luồng, dưới dạng từ khóa biến động.
Cũng giống như const đối trọng nổi tiếng hơn của nó, dễ bay hơi là một công cụ sửa đổi kiểu. Nó nhằm mục đích được sử dụng cùng với các biến được truy cập và sửa đổi trong các luồng khác nhau. Về cơ bản, nếu không có tính dễ bay hơi, việc viết các chương trình đa luồng sẽ trở nên bất khả thi, hoặc trình biên dịch sẽ lãng phí các cơ hội tối ưu hóa rộng lớn. Một lời giải thích là theo thứ tự.
Hãy xem xét đoạn mã sau:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
Mục đích của Gadget :: Wait ở trên là để kiểm tra biến flag_ member mỗi giây và trả về khi biến đó đã được một luồng khác đặt thành true. Ít nhất đó là những gì lập trình viên của nó dự định, nhưng, than ôi, Chờ là không chính xác.
Giả sử trình biên dịch chỉ ra rằng Sleep (1000) là một lệnh gọi vào thư viện bên ngoài mà không thể sửa đổi biến thành viên flag_. Sau đó, trình biên dịch kết luận rằng nó có thể cache flag_ trong một thanh ghi và sử dụng thanh ghi đó thay vì truy cập vào bộ nhớ trên bo mạch chậm hơn. Đây là một sự tối ưu hóa tuyệt vời cho mã một luồng, nhưng trong trường hợp này, nó gây hại cho tính đúng đắn: sau khi bạn gọi Wait đối với một số đối tượng Tiện ích, mặc dù một luồng khác gọi Wakeup, Wait sẽ lặp lại mãi mãi. Điều này là do sự thay đổi của flag_ sẽ không được phản ánh trong thanh ghi lưu trữ flag_. Tối ưu hóa quá ... lạc quan.
Bộ nhớ đệm các biến trong sổ đăng ký là một cách tối ưu hóa rất có giá trị được áp dụng hầu hết thời gian, vì vậy sẽ rất tiếc nếu lãng phí nó. C và C ++ cho bạn cơ hội vô hiệu hóa bộ nhớ đệm như vậy một cách rõ ràng. Nếu bạn sử dụng công cụ sửa đổi dễ thay đổi trên một biến, trình biên dịch sẽ không lưu biến đó vào bộ nhớ cache - mỗi lần truy cập sẽ truy cập vào vị trí bộ nhớ thực của biến đó. Vì vậy, tất cả những gì bạn phải làm để kết hợp Chờ / Đánh thức của Tiện ích hoạt động là đủ điều kiện flag_ một cách thích hợp:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
Hầu hết các giải thích về cơ sở lý luận và cách sử dụng biến đổi đều dừng ở đây và khuyên bạn nên đủ điều kiện biến động các loại nguyên thủy mà bạn sử dụng trong nhiều chuỗi. Tuy nhiên, bạn có thể làm được nhiều điều hơn nữa với dễ bay hơi, vì nó là một phần của hệ thống kiểu tuyệt vời của C ++.
Sử dụng dễ bay hơi với các loại do người dùng xác định
Bạn có thể xác định điều kiện dễ bay hơi không chỉ các kiểu nguyên thủy mà còn cả các kiểu do người dùng xác định. Trong trường hợp đó, variable sửa đổi kiểu theo cách tương tự như const. (Bạn cũng có thể áp dụng hằng số và biến đổi đồng thời cho cùng một loại.)
Không giống như const, biến động phân biệt giữa kiểu nguyên thủy và kiểu do người dùng định nghĩa. Cụ thể, không giống như các lớp, các kiểu nguyên thủy vẫn hỗ trợ tất cả các hoạt động của chúng (cộng, nhân, gán, v.v.) khi đủ điều kiện biến động. Ví dụ, bạn có thể gán một int không bay hơi cho một int dễ bay hơi, nhưng bạn không thể gán một đối tượng không bay hơi cho một đối tượng dễ bay hơi.
Hãy minh họa cách hoạt động của biến động trên các kiểu do người dùng xác định trên một ví dụ.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Nếu bạn nghĩ rằng tính dễ bay hơi không hữu ích với các đồ vật, hãy chuẩn bị cho một số bất ngờ.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
Việc chuyển đổi từ một loại không đủ điều kiện thành một đối tác dễ bay hơi của nó là không đáng kể. Tuy nhiên, cũng như với const, bạn không thể thực hiện chuyến đi từ dễ bay sang không đủ điều kiện. Bạn phải sử dụng dàn diễn viên:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Một lớp đủ điều kiện dễ bay hơi chỉ cấp quyền truy cập vào một tập con của giao diện của nó, một tập con nằm dưới sự kiểm soát của người triển khai lớp. Người dùng có thể có toàn quyền truy cập vào giao diện của loại đó chỉ bằng cách sử dụng const_cast. Ngoài ra, cũng giống như hằng số, tính dễ bay hơi truyền từ lớp đến các thành viên của nó (ví dụ: variableGadget.name_ và variableGadget.state_ là các biến dễ bay hơi).
Biến động, Phần quan trọng và Điều kiện cuộc đua
Thiết bị đồng bộ hóa đơn giản nhất và thường được sử dụng nhất trong các chương trình đa luồng là mutex. Một mutex cho thấy các nguyên thủy Thu nhận và Giải phóng. Khi bạn gọi Acquire trong một chuỗi nào đó, bất kỳ chuỗi nào khác đang gọi Acquire sẽ bị chặn. Sau đó, khi chuỗi đó gọi Release, chính xác một chuỗi bị chặn trong cuộc gọi Mua lại sẽ được giải phóng. Nói cách khác, đối với một mutex nhất định, chỉ một luồng có thể có được thời gian của bộ xử lý trong khoảng thời gian giữa lệnh gọi Mua và lệnh phát hành. Đoạn mã thực thi giữa lệnh gọi Mua lại và lệnh gọi Phát hành được gọi là phần quan trọng. (Thuật ngữ Windows hơi khó hiểu vì nó gọi bản thân mutex là một phần quan trọng, trong khi "mutex" thực sự là một mutex liên tiến trình. Sẽ rất tuyệt nếu chúng được gọi là mutex luồng và xử lý mutex.)
Mutexes được sử dụng để bảo vệ dữ liệu chống lại các điều kiện chủng tộc. Theo định nghĩa, một điều kiện chạy đua xảy ra khi ảnh hưởng của nhiều luồng hơn đối với dữ liệu phụ thuộc vào cách các luồng được lập lịch. Điều kiện cuộc đua xuất hiện khi hai hoặc nhiều chủ đề cạnh tranh để sử dụng cùng một dữ liệu. Bởi vì các luồng có thể ngắt nhau tại những thời điểm tùy ý trong thời gian, dữ liệu có thể bị hỏng hoặc hiểu sai. Do đó, các thay đổi và đôi khi quyền truy cập vào dữ liệu phải được bảo vệ cẩn thận với các phần quan trọng. Trong lập trình hướng đối tượng, điều này thường có nghĩa là bạn lưu trữ một mutex trong một lớp dưới dạng một biến thành viên và sử dụng nó bất cứ khi nào bạn truy cập trạng thái của lớp đó.
Các lập trình viên đa luồng có kinh nghiệm có thể đã ngáp khi đọc hai đoạn trên, nhưng mục đích của họ là cung cấp một bài tập trí tuệ, bởi vì bây giờ chúng ta sẽ liên kết với kết nối dễ bay hơi. Chúng tôi thực hiện điều này bằng cách vẽ một song song giữa thế giới các loại C ++ và thế giới ngữ nghĩa luồng.
- Bên ngoài một phần quan trọng, bất kỳ chuỗi nào cũng có thể làm gián đoạn bất kỳ phần nào khác bất kỳ lúc nào; không có sự kiểm soát, do đó, các biến có thể truy cập từ nhiều luồng rất dễ bay hơi. Điều này phù hợp với mục đích ban đầu của biến động - đó là ngăn trình biên dịch vô tình lưu trữ các giá trị được sử dụng bởi nhiều luồng cùng một lúc.
- Bên trong phần quan trọng được xác định bởi mutex, chỉ một luồng có quyền truy cập. Do đó, bên trong một phần quan trọng, mã thực thi có ngữ nghĩa đơn luồng. Biến được kiểm soát không còn dễ bay hơi nữa - bạn có thể xóa bộ định tính dễ bay hơi.
Nói tóm lại, dữ liệu được chia sẻ giữa các luồng là khái niệm dễ bay hơi bên ngoài phần quan trọng và không dễ bay hơi bên trong phần quan trọng.
Bạn nhập một phần quan trọng bằng cách khóa mutex. Bạn xóa bộ định tính biến động khỏi một loại bằng cách áp dụng const_cast. Nếu chúng ta quản lý để kết hợp hai hoạt động này với nhau, chúng ta sẽ tạo ra một kết nối giữa hệ thống kiểu của C ++ và ngữ nghĩa phân luồng của ứng dụng. Chúng tôi có thể làm cho trình biên dịch kiểm tra điều kiện cuộc đua cho chúng tôi.
KhóaPtr
Chúng tôi cần một công cụ thu thập chuyển đổi mutex và const_cast. Hãy phát triển một mẫu lớp LockingPtr mà bạn khởi tạo với một đối tượng biến động obj và mutex mtx. Trong suốt thời gian tồn tại của nó, một LockingPtr giữ cho mtx có được. Ngoài ra, LockingPtr cung cấp quyền truy cập vào đối tượng bị loại bỏ dễ bay hơi. Quyền truy cập được cung cấp theo kiểu con trỏ thông minh, thông qua toán tử-> và toán tử *. Const_cast được thực hiện bên trong LockingPtr. Việc ép kiểu hợp lệ về mặt ngữ nghĩa vì LockingPtr giữ mutex có được trong suốt thời gian tồn tại của nó.
Đầu tiên, hãy xác định khung của một lớp Mutex mà LockingPtr sẽ hoạt động:
class Mutex {
public:
void Acquire();
void Release();
...
};
Để sử dụng LockingPtr, bạn triển khai Mutex bằng cách sử dụng các cấu trúc dữ liệu gốc và các hàm nguyên thủy của hệ điều hành.
LockingPtr được tạo mẫu với kiểu của biến được điều khiển. Ví dụ: nếu bạn muốn kiểm soát một Widget, bạn sử dụng một LockingPtr mà bạn khởi tạo với một biến loại Widget dễ bay hơi.
Định nghĩa của LockingPtr rất đơn giản. LockingPtr triển khai một con trỏ thông minh không phức tạp. Nó chỉ tập trung vào việc thu thập một const_cast và một phần quan trọng.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
Mặc dù đơn giản, LockingPtr là một công cụ hỗ trợ rất hữu ích trong việc viết mã đa luồng chính xác. Bạn nên xác định các đối tượng được chia sẻ giữa các luồng là dễ bay hơi và không bao giờ sử dụng const_cast với chúng - luôn sử dụng các đối tượng tự động LockingPtr. Hãy minh họa điều này bằng một ví dụ.
Giả sử bạn có hai luồng dùng chung một đối tượng vectơ:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
Bên trong một hàm luồng, bạn chỉ cần sử dụng một LockingPtr để có quyền truy cập có kiểm soát vào biến buffer_ member:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
Mã này rất dễ viết và dễ hiểu - bất cứ khi nào bạn cần sử dụng buffer_, bạn phải tạo một LockingPtr trỏ đến nó. Khi bạn làm điều đó, bạn có quyền truy cập vào toàn bộ giao diện của vector.
Phần hay là nếu bạn mắc lỗi, trình biên dịch sẽ chỉ ra:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
Bạn không thể truy cập bất kỳ chức năng nào của buffer_ cho đến khi bạn áp dụng const_cast hoặc sử dụng LockingPtr. Sự khác biệt là LockingPtr cung cấp một cách có thứ tự để áp dụng const_cast cho các biến dễ bay hơi.
LockingPtr là biểu cảm đáng kể. Nếu bạn chỉ cần gọi một hàm, bạn có thể tạo một đối tượng LockingPtr tạm thời không tên và sử dụng trực tiếp:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Quay lại các loại nguyên thủy
Chúng tôi đã thấy cách dễ dàng thay đổi bảo vệ các đối tượng khỏi truy cập không kiểm soát và cách LockingPtr cung cấp một cách đơn giản và hiệu quả để viết mã an toàn theo luồng. Bây giờ chúng ta hãy quay trở lại các kiểu nguyên thủy, được xử lý khác nhau bằng biến động.
Hãy xem xét một ví dụ trong đó nhiều luồng chia sẻ một biến kiểu int.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Nếu Tăng và Giảm được gọi từ các luồng khác nhau, thì phân đoạn trên là lỗi. Đầu tiên, ctr_ phải dễ bay hơi. Thứ hai, ngay cả một phép toán có vẻ nguyên tử như ++ ctr_ cũng thực sự là một phép toán ba giai đoạn. Bộ nhớ tự nó không có khả năng số học. Khi tăng một biến, bộ xử lý:
- Đọc biến đó trong một thanh ghi
- Tăng giá trị trong thanh ghi
- Ghi kết quả trở lại bộ nhớ
Thao tác ba bước này được gọi là RMW (Read-Modify-Write). Trong phần Sửa đổi của hoạt động RMW, hầu hết các bộ xử lý giải phóng bus bộ nhớ để cấp cho các bộ xử lý khác quyền truy cập vào bộ nhớ.
Nếu tại thời điểm đó một bộ xử lý khác thực hiện một hoạt động RMW trên cùng một biến, chúng ta có một điều kiện đua: lần ghi thứ hai sẽ ghi đè lên tác động của lần đầu tiên.
Để tránh điều đó, một lần nữa, bạn có thể dựa vào LockingPtr:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Bây giờ mã là chính xác, nhưng chất lượng của nó kém hơn khi so sánh với mã của SyncBuf. Tại sao? Vì với Counter, trình biên dịch sẽ không cảnh báo nếu bạn truy cập nhầm vào ctr_ trực tiếp (mà không cần khóa). Trình biên dịch biên dịch ++ ctr_ nếu ctr_ dễ bay hơi, mặc dù mã được tạo đơn giản là không chính xác. Trình biên dịch không còn là đồng minh của bạn nữa, và chỉ sự chú ý của bạn mới có thể giúp bạn tránh được các điều kiện về chủng tộc.
Bạn nên làm gì tiếp theo? Đơn giản chỉ cần đóng gói dữ liệu nguyên thủy mà bạn sử dụng trong các cấu trúc cấp cao hơn và sử dụng dễ bay hơi với các cấu trúc đó. Nghịch lý là, sẽ tệ hơn nếu sử dụng trực tiếp dễ bay hơi với các bản cài sẵn, mặc dù thực tế ban đầu đây là mục đích sử dụng của dễ bay hơi!
Chức năng thành viên dễ thay đổi
Cho đến nay, chúng tôi đã có các lớp tổng hợp các thành viên dữ liệu dễ bay hơi; bây giờ chúng ta hãy nghĩ đến việc thiết kế các lớp sẽ là một phần của các đối tượng lớn hơn và được chia sẻ giữa các luồng. Đây là nơi mà các hàm thành viên dễ bay hơi có thể giúp ích rất nhiều.
Khi thiết kế lớp của mình, bạn chỉ đủ tiêu chuẩn biến động đối với những hàm thành viên an toàn cho luồng. Bạn phải giả định rằng mã từ bên ngoài sẽ gọi các hàm biến động từ mã bất kỳ lúc nào. Đừng quên: dễ bay hơi bằng mã đa luồng miễn phí và không có phần quan trọng; không bay hơi bằng kịch bản đơn luồng hoặc bên trong một phần quan trọng.
Ví dụ: bạn xác định một Widget lớp thực hiện một hoạt động trong hai biến thể - một biến thể an toàn theo luồng và một biến thể nhanh, không được bảo vệ.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Lưu ý việc sử dụng quá tải. Giờ đây, người dùng của Widget có thể gọi Thao tác bằng một cú pháp thống nhất cho các đối tượng dễ bay hơi và có được sự an toàn của luồng, hoặc đối với các đối tượng thông thường và có được tốc độ. Người dùng phải cẩn thận về việc xác định các đối tượng Widget được chia sẻ là dễ bay hơi.
Khi thực hiện một chức năng thành viên dễ bay hơi, thao tác đầu tiên thường là khóa chức năng này bằng LockingPtr. Sau đó, công việc được thực hiện bằng cách sử dụng anh chị em không bay hơi:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Tóm lược
Khi viết các chương trình đa luồng, bạn có thể sử dụng dễ bay hơi để có lợi cho mình. Bạn phải tuân thủ các quy tắc sau:
- Xác định tất cả các đối tượng được chia sẻ là dễ bay hơi.
- Không sử dụng trực tiếp dễ bay hơi với các loại nguyên thủy.
- Khi xác định các lớp chia sẻ, hãy sử dụng các hàm thành viên dễ bay hơi để thể hiện sự an toàn của luồng.
Nếu bạn làm điều này và nếu bạn sử dụng thành phần chung đơn giản LockingPtr, bạn có thể viết mã an toàn cho luồng và ít lo lắng hơn về điều kiện chủng tộc, bởi vì trình biên dịch sẽ lo lắng cho bạn và sẽ chăm chỉ chỉ ra những điểm bạn sai.
Một vài dự án tôi đã tham gia sử dụng dễ bay hơi và LockingPtr mang lại hiệu quả tuyệt vời. Mã rõ ràng và dễ hiểu. Tôi nhớ lại một vài deadlock, nhưng tôi thích deadlocks hơn là các điều kiện chạy đua vì chúng dễ gỡ lỗi hơn rất nhiều. Hầu như không có vấn đề gì liên quan đến điều kiện chủng tộc. Nhưng sau đó bạn không bao giờ biết.
Sự nhìn nhận
Rất cảm ơn James Kanze và Sorin Jianu, những người đã giúp đưa ra những ý tưởng sâu sắc.
Andrei Alexandrescu là Giám đốc phát triển tại RealNetworks Inc. (www.realnetworks.com), có trụ sở tại Seattle, WA và là tác giả của cuốn sách nổi tiếng Thiết kế C ++ hiện đại. Anh ta có thể được liên hệ tại www.moderncppdesign.com. Andrei cũng là một trong những giảng viên tiêu biểu của The C ++ Seminar (www.gotw.ca/cpp_seminar).
Bài viết này có thể hơi cũ, nhưng nó cung cấp cái nhìn sâu sắc về việc sử dụng tuyệt vời công cụ sửa đổi biến động trong việc sử dụng lập trình đa luồng để giúp giữ cho các sự kiện không đồng bộ trong khi trình biên dịch kiểm tra điều kiện chủng tộc cho chúng tôi. Điều này có thể không trực tiếp trả lời câu hỏi ban đầu của OP về việc tạo hàng rào bộ nhớ, nhưng tôi chọn đăng điều này như một câu trả lời cho những người khác như một tài liệu tham khảo tuyệt vời hướng tới việc sử dụng tốt biến động khi làm việc với các ứng dụng đa luồng.