CFSDN nhấn mạnh vào giá trị tạo ra nguồn mở và chúng tôi cam kết xây dựng nền tảng chia sẻ tài nguyên để mọi nhân viên CNTT có thể tìm thấy thế giới tuyệt vời của bạn tại đây.
Bài blog CFSDN này giải thích chi tiết về chế độ đồng bộ bộ nhớ (thứ tự bộ nhớ) trong C++, được tác giả sưu tầm và biên soạn. Nếu bạn quan tâm đến bài viết này thì nhớ like nhé.
Chế độ đồng bộ hóa mô hình bộ nhớ.
Đồng bộ hóa biến nguyên tử là phần khó hiểu nhất của mô hình bộ nhớ. Chức năng chính của biến nguyên tử là đồng bộ hóa quyền truy cập bộ nhớ dùng chung giữa nhiều luồng. Nói chung, một luồng sẽ tạo một số dữ liệu và sau đó đặt giá trị Flag của biến nguyên tử (Chú thích: the). biến nguyên tử ở đây tương tự như một lá cờ); các chủ đề khác đọc điều này Đối với các biến nguyên tử, khi phát hiện thấy giá trị của nó đã thay đổi thành giá trị cờ, dữ liệu được chia sẻ trong luồng trước đó lẽ ra đã được tạo và có thể được đọc trong luồng hiện tại. Các chế độ đồng bộ hóa bộ nhớ khác nhau sẽ xác định "độ mạnh" của dữ liệu. cơ chế chia sẻ giữa các luồng Ở mức độ "yếu hơn", các lập trình viên có kinh nghiệm có thể sử dụng các chế độ đồng bộ hóa "yếu hơn" để cải thiện hiệu quả thực hiện chương trình. .
Mỗi loại nguyên tử có một phương thức load() (cho các hoạt động tải) và một phương thức store() (cho các hoạt động lưu trữ). Việc sử dụng các phương thức này (thay vì các hoạt động đọc thông thường) có thể đánh dấu mã rõ ràng hơn.
?
1
2
3
|
atomic_var1.store(atomic_var2.load());
so với
var1 = var2;
|
Các phương pháp này cũng hỗ trợ một tham số tùy chọn có thể được sử dụng để chỉ định chế độ đồng bộ hóa của mô hình bộ nhớ.
Hiện tại có ba chế độ bộ nhớ được sử dụng để đồng bộ hóa giữa các luồng. Hãy cùng xem xét chúng nhé~.
Chế độ nhất quán tuần tự.
Chế độ đầu tiên là chế độ nhất quán tuần tự, cũng là chế độ mặc định cho các hoạt động nguyên tử và chế độ hạn chế nhất. Chúng ta có thể chỉ định rõ ràng chế độ này thông qua std::memory_order_seq_cst. Trong chế độ này, các hạn chế về sắp xếp lại lệnh giữa các luồng là phù hợp với. các hạn chế về sắp xếp lại lệnh trong mã tuần tự.
Quan sát đoạn mã sau
?
1
2
3
|
-Chủ đề 1- -Chủ đề 2-
y = 1
nếu như
(x.load() == 2)
x. cửa hàng (2);
khẳng định
(y == 1)
|
Mặc dù x và y trong mã là hai biến không liên quan với nhau, mô hình bộ nhớ được chỉ định trong mã (Chú thích: Nếu nó không được chỉ định rõ ràng trong mã, thì chế độ bộ nhớ mặc định, cụ thể là chế độ nhất quán tuần tự, sẽ được sử dụng) đảm bảo xác nhận trong luồng 2 Nó sẽ không thất bại. Việc ghi vào y trong luồng 1 xảy ra - trước khi ghi vào x. Nếu luồng 2 đọc ghi vào x từ luồng 1 (x.load() == 2), thì trong luồng 1, đối với x. Tất cả các thao tác ghi trước khi ghi phải hiển thị với luồng 2, ngay cả những thao tác không liên quan đến x. Điều này có nghĩa là việc tối ưu hóa không thể lên lịch lại hai lần ghi trong luồng 1 (y = 1 và x.store (2)), bởi vì. khi luồng 2 đọc ghi của luồng 1 vào x, thì ghi của luồng 1 vào y cũng phải hiển thị với luồng 2. .
(Chú thích: Trình biên dịch hoặc CPU sẽ sắp xếp lại các lệnh mã do các yếu tố hiệu suất. Thao tác sắp xếp lại này không thể nhận thấy đối với các chương trình đơn luồng, nhưng không thể chấp nhận được đối với các chương trình đa luồng. Lấy đoạn mã trên làm ví dụ. Nếu sắp xếp lại x.store (2 ) trước y = 1 thì ngay cả khi luồng 2 đọc và tìm thấy x == 2 thì giá trị của y không nhất thiết phải là 1) tại thời điểm này.
Hoạt động tải có những hạn chế tối ưu hóa tương tự
?
1
2
3
4
5
6
7
8
|
một = 0
y = 0
b = 1
-Chủ đề 1- -Chủ đề 2-
x = a. tải()
trong khi
(y.load() != b)
y.cửa hàng (b);
trong khi
(a.load() == x) a.store(1)
;
|
Luồng 2 tiếp tục lặp cho đến khi giá trị của y thay đổi, sau đó gán một giá trị; Luồng 1 tiếp tục chờ giá trị của a thay đổi.
Từ góc độ mã tuần tự, mã 'while (a.load() == x)' trong luồng 1 dường như là một vòng lặp vô hạn. Khi trình biên dịch biên dịch mã này, nó có thể trực tiếp tối ưu hóa nó thành một vòng lặp vô hạn. (Chú thích: được tối ưu hóa cho các lệnh như while (true);); nhưng trên thực tế, chúng ta phải đảm bảo rằng mỗi vòng lặp thực hiện thao tác đọc (a.load()) trên a và so sánh nó với x, nếu không Thread 1 và Thread 2 sẽ không hoạt động bình thường (Chú thích: Chủ đề 1 Nó sẽ đi vào một vòng lặp vô hạn, không phù hợp với kết quả thực hiện đúng).
Từ quan điểm thực tế, tất cả các hoạt động nguyên tử đều tương đương với các rào cản tối ưu hóa (Chú thích: hướng dẫn được sử dụng để ngăn chặn các hoạt động tối ưu hóa). giữa các hoạt động nguyên tử. Trình tự mã có thể được điều chỉnh tùy ý trong quá trình, nhưng nó không thể vượt qua hoạt động nguyên tử (Chú thích: Hoạt động nguyên tử tương tự như ranh giới điều chỉnh tối ưu hóa). điều này, bởi vì những dữ liệu này không hiển thị với các chủ đề khác.
Chế độ nhất quán tuần tự cũng đảm bảo tính nhất quán về thứ tự sửa đổi của các biến nguyên tử (sử dụng chế độ Memory_order_seq_cst) trên tất cả các luồng. Tất cả các xác nhận trong đoạn mã sau sẽ không bị lỗi (giá trị ban đầu của x và y là 0)
?
1
2
3
4
5
|
-Chủ đề 1- -Chủ đề 2- -Chủ đề 3-
y.cửa hàng (20);
nếu như
(x.load() == 10) {
nếu như
(y.load() == 10)
x. cửa hàng (10);
khẳng định
(y.load() == 20)
khẳng định
(x.load() == 10)
y.store (10)
}
|
Từ góc độ mã tuần tự, có vẻ như điều này (tất cả các xác nhận sẽ không thất bại) được coi là đương nhiên, nhưng trong môi trường đa luồng, chúng ta phải đồng bộ hóa bus hệ thống để đạt được hiệu ứng này (để luồng 3 không giống như thread 2). Các biến nguyên tử (sử dụng chế độ Memory_order_seq_cst) thay đổi theo cùng một thứ tự), như bạn có thể tưởng tượng, điều này thường đòi hỏi phải đồng bộ hóa phần cứng đắt tiền.
Do đặc tính đảm bảo tính nhất quán tuần tự, chế độ nhất quán tuần tự đã trở thành chế độ bộ nhớ mặc định được sử dụng trong các hoạt động nguyên tử. Khi các lập trình viên sử dụng chế độ này, họ thường ít có khả năng nhận được các kết quả chương trình không mong muốn.
Chế độ thư giãn (thư giãn).
Ngược lại với chế độ nhất quán tuần tự là chế độ std::memory_order_relaxed, đây là chế độ thoải mái vì hạn chế của mối quan hệ xảy ra trước khi bị loại bỏ, chế độ thoải mái có thể được thực hiện chỉ với một vài hướng dẫn đồng bộ hóa. Khác với chế độ nhất quán tuần tự trước đó, chúng ta có thể thực hiện nhiều tối ưu hóa khác nhau đối với các hoạt động biến nguyên tử, chẳng hạn như thực hiện xóa mã chết, v.v.
Hãy xem ví dụ trước
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
-Chủ đề 1-
y.store (20, memory_order_relaxed)
x.store (10, memory_order_relaxed)
-Chủ đề 2-
nếu như
(x.load (memory_order_relaxed) == 10)
{
khẳng định
(y.load(memory_order_relaxed) == 20)
y.store (10, memory_order_relaxed)
}
-Chủ đề 3-
nếu như
(y.load (memory_order_relaxed) == 10)
khẳng định
(x.load(memory_order_relaxed) == 10)
|
Do không còn cần đồng bộ hóa giữa các luồng (Chú thích: Do sử dụng chế độ thoải mái, mối quan hệ đồng bộ hóa không còn được hình thành giữa các hoạt động nguyên tử và không cần đồng bộ hóa ở đây có nghĩa là không cần phải đồng bộ hóa giữa các hoạt động nguyên tử), do đó, bất kỳ xác nhận nào trong mã Tất cả có thể thất bại...
Vì không có mối quan hệ xảy ra trước đó, từ góc nhìn của một luồng đơn lẻ, không còn một thứ tự ghi biến nguyên tử cụ thể nào hiển thị cho các luồng khác. Chế độ thư giãn có thể gây ra nhiều vấn đề nếu không được sử dụng cẩn thận. Điều đảm bảo duy nhất của chế độ này là khi luồng 2 quan sát giá trị ghi của một biến nguyên tử trong luồng 1, thì luồng 2 sẽ không còn thấy giá trị ghi trước đó vào biến theo luồng 1. Giá trị số.
Hãy xem một ví dụ (giả sử giá trị ban đầu của x là 0)
?
1
2
3
4
5
6
7
8
|
-Chủ đề 1-
x.store (1, memory_order_relaxed)
x.store (2, memory_order_relaxed)
-Chủ đề 2-
y = x.tải (memory_order_relaxed)
z = x.load (bộ nhớ_thứ_tự_giải_trí)
khẳng định
(y <= z)
|
Xác nhận trong mã sẽ không thất bại Khi luồng 2 đọc giá trị của x là 2, thì thao tác đọc tiếp theo của x theo luồng 2 sẽ không thể nhận được giá trị 1 (1 là giá trị được viết trước đó của x hơn 2). ) .Tính năng này dẫn đến một kết quả: nếu có nhiều lần đọc ở chế độ thoải mái của cùng một biến trong mã, nhưng có nhiều lần đọc ở chế độ thoải mái của các tham chiếu khác (có thể là bí danh của cùng một biến trước đó) giữa các lần đọc này, thì Chúng ta không thể kết hợp nhiều chế độ đọc thoải mái của cùng một biến (nhiều lần đọc thành một). .
Ở đây cũng có một giả định rằng việc ghi thoải mái vào một biến nguyên tử của một luồng sẽ được hiển thị đối với một luồng khác (thông qua việc đọc thoải mái) trong một khoảng thời gian hợp lý. Điều này có nghĩa là trên một số kiến trúc không kết hợp với bộ nhớ đệm, sẽ được thoải mái. thao tác cần chủ động làm mới bộ đệm (tất nhiên, các thao tác làm mới có thể được hợp nhất, chẳng hạn như thực hiện thao tác làm mới sau nhiều thao tác lỏng lẻo).
Kịch bản được sử dụng phổ biến nhất cho chế độ thư giãn là khi chúng ta chỉ cần một biến nguyên tử và không cần sử dụng biến nguyên tử đó để đồng bộ hóa bộ nhớ dùng chung giữa các luồng (Chú thích: chẳng hạn như bộ đếm nguyên tử).
Chế độ thu nhận/giải phóng (thu nhận/giải phóng).
Chế độ thứ ba kết hợp hai chế độ trước đó Chế độ thu thập/giải phóng tương tự như chế độ nhất quán tuần tự trước đó, ngoại trừ chế độ này chỉ đảm bảo mối quan hệ xảy ra trước đó giữa các biến phụ thuộc. và các thao tác ghi độc lập.
Giả sử giá trị ban đầu của x và y là 0
?
1
2
3
4
5
6
7
8
9
10
11
|
-Chủ đề 1-
y.store (20, lệnh phát hành bộ nhớ);
-Chủ đề 2-
x.store(10, memory_order_release);
-Chủ đề 3-
khẳng định
(y.load (memory_order_acquire) == 20 && x.load (memory_order_acquire) == 0)
-Chủ đề 4-
khẳng định
(y.load (memory_order_acquire) == 0 && x.load (memory_order_acquire) == 10)
|
Hai xác nhận trong mã có thể vượt qua cùng một lúc, vì hai thao tác ghi trong luồng 1 và luồng 2 không tuần tự.
Nhưng nếu chúng ta viết lại đoạn mã trên bằng chế độ nhất quán tuần tự, thì một trong hai thao tác ghi phải xảy ra trước thao tác kia (mặc dù trình tự thực tế chỉ có thể được xác định khi chạy và thứ tự này nhất quán trên nhiều luồng (thông qua đồng bộ hóa cần thiết). hoạt động), vì vậy nếu một xác nhận trong mã vượt qua thì xác nhận còn lại chắc chắn sẽ thất bại.
Nếu chúng ta sử dụng các biến phi nguyên tử trong mã của mình, mọi thứ sẽ phức tạp hơn một chút, nhưng khả năng hiển thị của các biến phi nguyên tử này giống như khi chúng là các biến nguyên tử (Viết trước khi sử dụng chế độ phát hành) đều hiển thị. sang các luồng được đồng bộ hóa khác (sử dụng chế độ thu thập và đọc giá trị được ghi trước đó ở chế độ phát hành).
?
1
2
3
4
5
6
7
|
-Chủ đề 1-
y = 20;
x.store(10, memory_order_release);
-Chủ đề 2-
nếu như
(x.load(memory_order_acquire) == 10)
khẳng định
(y == 20);
|
Việc ghi vào y trong luồng 1 (y = 20) xảy ra trước khi ghi vào x (x.store (10, Memory_order_release)), do đó, xác nhận trong luồng 2 sẽ không thất bại. trong luồng 1 xảy ra đầu tiên trước khi ghi x và việc ghi x trong luồng 1 được đồng bộ hóa với việc đọc x trong luồng 2. Vì việc ghi x trong luồng 2 Việc đọc y xảy ra đầu tiên trong xác nhận của y, do đó việc ghi y trong luồng 1 xảy ra đầu tiên trong xác nhận của y trong luồng 2 và việc xác nhận y sẽ không thất bại. Do yêu cầu đồng bộ hóa ở trên, bộ nhớ dùng chung). (Biến phi nguyên tử) xung quanh các hoạt động nguyên tử cũng có các hạn chế tối ưu hóa (Chú thích: Các thao tác này không thể được tối ưu hóa theo ý muốn. Lấy đoạn mã trên làm ví dụ, thao tác tối ưu hóa không thể sắp xếp lại y = 20 trong x.store (10, Memory_order_release) sau đó). .
Chế độ tiêu thụ/giải phóng (tiêu thụ/giải phóng).
Chế độ tiêu thụ/giải phóng là một cải tiến hơn nữa ở chế độ thu thập/giải phóng. Trong chế độ này, mối quan hệ xuất hiện trước của các biến chia sẻ không phụ thuộc không còn được giữ nguyên.
Giả sử n và m là hai biến chung có giá trị ban đầu là 0 và giả sử cả luồng 2 và luồng 3 đều đã đọc phần ghi vào biến nguyên tử p trong luồng 1 (Chú thích: Chú ý đến tiền đề mã) .
?
1
2
3
4
5
6
7
8
9
10
11
12
|
-Chủ đề 1-
n = 1
m = 1
p.store (&n, memory_order_release)
-Chủ đề 2-
t = p. tải (bộ nhớ_đặt_hàng_thu thập);
khẳng định
( *t == 1 && m == 1 );
-Chủ đề 3-
t = p. tải (bộ nhớ_đặt_hàng_tiêu_dùng);
khẳng định
( *t == 1 && m == 1 );
|
Xác nhận trong luồng 2 sẽ không thất bại vì việc ghi vào m trong luồng 1 xảy ra trước khi ghi vào p.
Tuy nhiên, xác nhận trong luồng 3 có thể thất bại vì p và m không có sự phụ thuộc và chế độ tiêu thụ được sử dụng để đọc p trong luồng 3, dẫn đến việc ghi m trong luồng 1 không nhất quán với xác nhận trong luồng 3. Nếu mối quan hệ xảy ra trước tiên, xác nhận có thể tự nhiên thất bại Trong kiến trúc PowerPC và kiến trúc ARM, chế độ bộ nhớ mặc định để tải con trỏ là chế độ tiêu thụ (điều này cũng có thể đúng với một số kiến trúc MIPS).
Ngoài ra, cả thread 1 và thread 2 đều có thể đọc chính xác giá trị của n, vì n và p có mối quan hệ phụ thuộc (Chú thích: p.store(&n,memory_order_release), địa chỉ của n được viết bằng p nên p và p n hình thành mối quan hệ phụ thuộc).
Sự khác biệt thực sự giữa các chế độ bộ nhớ thực tế là số trạng thái mà phần cứng cần làm mới để đồng bộ hóa. So với chế độ thu nhận/giải phóng, chế độ tiêu thụ/giải phóng sẽ thực thi nhanh hơn và có thể được sử dụng trong một số chương trình cực kỳ nhạy cảm với hiệu suất. ..
Tóm tắt.
Chế độ bộ nhớ không phức tạp như bạn tưởng. Để hiểu sâu hơn, hãy xem ví dụ này.
?
1
2
3
4
5
6
7
8
9
10
11
12
13
|
-Chủ đề 1-
y.cửa hàng (20);
x. cửa hàng (10);
-Chủ đề 2-
nếu như
(x.load() == 10) {
khẳng định
(y.load() == 20)
y.store (10)
}
-Chủ đề 3-
nếu như
(y.load() == 10)
khẳng định
(x.load() == 10)
|
Khi sử dụng chế độ nhất quán tuần tự, tất cả các biến được chia sẻ sẽ được đồng bộ hóa giữa các luồng, do đó cả hai xác nhận trong luồng 2 và luồng 3 sẽ không bị lỗi.
?
1
2
3
4
5
6
7
8
9
10
11
12
13
|
-Chủ đề 1-
y.store (20, lệnh phát hành bộ nhớ);
x.store(10, memory_order_release);
-Chủ đề 2-
nếu như
(x.load(memory_order_acquire) == 10) {
khẳng định
(y.load(memory_order_acquire) == 20)
y.store (10, memory_order_release)
}
-Chủ đề 3-
nếu như
(y.load(memory_order_acquire) == 10)
khẳng định
(x.load(memory_order_acquire) == 10)
|
Chế độ thu thập/giải phóng chỉ yêu cầu đồng bộ hóa cần thiết giữa hai luồng (một luồng sử dụng chế độ phát hành và một luồng sử dụng chế độ thu thập. Điều này có nghĩa là các biến được đồng bộ hóa giữa hai luồng này không nhất thiết phải hiển thị với các luồng khác. Xác nhận trong Thread 2 sẽ không hiển thị). vẫn không bị lỗi vì Thread 1 và Thread 2 hình thành mối quan hệ đồng bộ thông qua việc ghi và đọc Tham gia vào việc đồng bộ hóa thread 1 và thread 2 nên khi thread 2 và thread 3 vượt qua y Khi mối quan hệ đồng bộ hóa giữa ghi và đọc xảy ra, luồng 1 và luồng 3 không có mối quan hệ đồng bộ hóa. Đương nhiên, giá trị của x không nhất thiết phải hiển thị đối với luồng 3, do đó xác nhận trong luồng 3 có thể không thành công.
?
1
2
3
4
5
6
7
8
9
10
11
12
13
|
-Chủ đề 1-
y.store (20, lệnh phát hành bộ nhớ);
x.store(10, memory_order_release);
-Chủ đề 2-
nếu như
(x.load(bộ nhớ_đặt_hàng_tiêu_thụ) == 10) {
khẳng định
(y.load(bộ nhớ_đặt_hàng_tiêu_dùng) == 20)
y.store (10, memory_order_release)
}
-Chủ đề 3-
nếu như
(y.load(memory_order_consume) == 10)
khẳng định
(x.load(memory_order_consume) == 10)
|
Kết quả của việc sử dụng chế độ tiêu thụ/giải phóng cũng giống như chế độ thu nhận/giải phóng. Sự khác biệt là chế độ tiêu thụ/giải phóng yêu cầu ít thao tác đồng bộ hóa phần cứng hơn (thay vì sử dụng chế độ này). sử dụng chế độ thu thập/giải phóng)? Đó là vì không có biến chia sẻ (phi nguyên tử) nào liên quan đến ví dụ này. Nếu y trong ví dụ là biến chia sẻ (không nguyên tử), vì nó có liên quan đến x. Không có sự phụ thuộc (sự phụ thuộc có nghĩa là giá trị ghi của biến nguyên tử được tính từ biến chia sẻ (không phải nguyên tử), thì chúng ta không nhất thiết có thể thấy giá trị hiện tại của y (20) trong luồng 2, thậm chí if thread 2 Giá trị của x đã được đọc là 10. .
(Chú thích: Sẽ không chính xác khi nói rằng vì không có biến chia sẻ (không nguyên tử) nào liên quan nên chế độ tiêu thụ/giải phóng và chế độ thu nhận/giải phóng hoạt động giống nhau. Sửa đổi khẳng định (y.load(memory_order_consume) == 20 ) trong ví dụ. Đối với khẳng định (y.load(memory_order_relaxed) == 20), nó cũng phải phản ánh sự khác biệt giữa chế độ tiêu thụ/giải phóng và chế độ thu nhận/giải phóng. Để biết thêm chi tiết, vui lòng tham khảo ví dụ ở cuối bài viết. ) .
?
1
2
3
4
5
6
7
8
9
10
11
12
13
|
-Chủ đề 1-
y.store (20, memory_order_relaxed);
x.store(10, memory_order_relaxed);
-Chủ đề 2-
nếu như
(x.load(memory_order_relaxed) == 10) {
khẳng định
(y.load(memory_order_relaxed) == 20)
y.store (10, memory_order_relaxed)
}
-Chủ đề 3-
nếu như
(y.load(memory_order_relaxed) == 10)
khẳng định
(x.load(memory_order_relaxed) == 10)
|
Nếu tất cả các thao tác đang sử dụng chế độ thoải mái thì cả hai xác nhận trong mã có thể không thành công do không có thao tác đồng bộ hóa nào xảy ra ở chế độ thoải mái.
Trộn các chế độ bộ nhớ.
Cuối cùng, hãy xem điều gì xảy ra khi bạn kết hợp các chế độ bộ nhớ
?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
-Chủ đề 1-
y.store (20, memory_order_relaxed)
x.store (10, thứ tự bộ nhớ_seq_cst)
-Chủ đề 2-
nếu như
(x.load (memory_order_relaxed) == 10)
{
khẳng định
(y.load(memory_order_seq_cst) == 20)
y.store (10, memory_order_relaxed)
}
-Chủ đề 3-
nếu như
(y.load (memory_order_acquire) == 10)
khẳng định
(x.load(memory_order_acquire) == 10)
|
Trước hết, tôi phải cảnh báo bạn không nên làm điều này (trộn các chế độ bộ nhớ) vì nó có thể cực kỳ khó hiểu.
Cuối cùng, bài viết giải thích chi tiết về chế độ đồng bộ hóa bộ nhớ (thứ tự bộ nhớ) trong C++ kết thúc tại đây. Nếu bạn muốn biết thêm về giải thích chi tiết về chế độ đồng bộ hóa bộ nhớ (thứ tự bộ nhớ) trong C++, vui lòng tìm kiếm bài viết CFSDN hoặc. tiếp tục duyệt các bài viết liên quan, tôi hy vọng bạn sẽ ủng hộ blog của tôi trong tương lai! .
Tôi là một lập trình viên xuất sắc, rất giỏi!