Các phiên bản nền tảng Android 3.0 trở lên được tối ưu hoá để hỗ trợ cấu trúc đa xử lý. Tài liệu này giới thiệu các vấn đề có thể phát sinh khi viết mã đa luồng cho hệ thống đa xử lý đối xứng trong C, C++ và Java ngôn ngữ lập trình (sau đây được gọi đơn giản là "Java" nhằm mục đích ngắn gọn). Đây chỉ là một cuốn sách cơ bản dành cho các nhà phát triển ứng dụng Android chứ không phải là một tài liệu hoàn chỉnh thảo luận về chủ đề này.
Giới thiệu
SMP là viết tắt của "Symmetric Multi-Processor". Phần này mô tả một thiết kế theo có hai hoặc nhiều lõi CPU giống hệt nhau có chung quyền truy cập vào bộ nhớ chính. Cho đến vài năm trước, tất cả các thiết bị Android đều được UP (Bộ xử lý đơn giản).
Hầu hết (nếu không phải tất cả) thiết bị Android luôn có nhiều CPU, nhưng trước đây, chỉ một trong số đó được dùng để chạy ứng dụng, còn các CPU khác quản lý nhiều phần cứng thiết bị (ví dụ: đài phát thanh). Các CPU có thể có kiến trúc khác nhau và các chương trình đang chạy trên chúng không thể sử dụng bộ nhớ chính để giao tiếp với nhau khác.
Hầu hết thiết bị Android bán ra hiện nay đều được xây dựng dựa trên thiết kế SMP, khiến mọi thứ trở nên phức tạp hơn một chút đối với các nhà phát triển phần mềm. Tình huống tương tranh trong một chương trình đa luồng có thể không gây ra vấn đề rõ ràng trên một đơn xử lý, nhưng thường xuyên gặp sự cố khi hai hoặc nhiều luồng của bạn đang chạy đồng thời trên các lõi khác nhau. Hơn nữa, mã có thể ít hoặc nhiều khả năng gặp lỗi khi chạy trên các nền tảng khác nhau kiến trúc bộ xử lý hay thậm chí trên các cách triển khai khác nhau cấu trúc. Mã đã được kiểm tra kỹ lưỡng trên x86 có thể bị lỗi trên ARM. Mã có thể bắt đầu gặp lỗi khi được biên dịch lại bằng một trình biên dịch hiện đại hơn.
Phần còn lại của tài liệu này sẽ giải thích lý do và cho bạn biết việc bạn cần làm để đảm bảo rằng mã của bạn hoạt động chính xác.
Mô hình tính nhất quán của bộ nhớ: Lý do khiến SMP có chút khác biệt
Đây là nội dung tổng quan đơn giản, tốc độ cao về một chủ đề phức tạp. Một số khu vực sẽ chưa đầy đủ nhưng không được gây hiểu lầm hoặc không chính xác. Khi bạn sẽ xem trong phần tiếp theo. Các thông tin chi tiết ở đây thường không quan trọng.
Hãy xem phần Đọc thêm ở cuối tài liệu để biết các chỉ dẫn về cách xử lý kỹ lưỡng hơn về chủ đề này.
Các mô hình tính nhất quán của bộ nhớ, hay thường chỉ là "mô hình bộ nhớ", mô tả đảm bảo ngôn ngữ lập trình hoặc kiến trúc phần cứng trợ giúp về quyền truy cập bộ nhớ. Ví dụ: nếu bạn ghi một giá trị vào địa chỉ A, sau đó ghi một giá trị vào địa chỉ B, thì mô hình có thể đảm bảo rằng mọi lõi CPU đều thấy các lần ghi đó diễn ra theo thứ tự đó.
Mô hình mà hầu hết các lập trình viên đều quen thuộc là tuần tự nhất quán, được mô tả như sau (Adve & Gharachorloo):
- Tất cả thao tác đối với bộ nhớ sẽ có vẻ thực thi lần lượt từng thao tác
- Tất cả các thao tác trong một luồng dường như thực thi theo thứ tự do chương trình của bộ xử lý đó mô tả.
Tạm giả sử chúng ta có một trình biên dịch hoặc trình thông dịch rất đơn giản không gây bất ngờ: Trình biên dịch này dịch các chỉ định trong mã nguồn để tải và lưu trữ các lệnh theo đúng thứ tự tương ứng, một lệnh cho mỗi quyền truy cập. Chúng tôi cũng sẽ giả định cho tính đơn giản là mỗi luồng thực thi trên bộ xử lý của chính nó.
Nếu bạn nhìn vào một đoạn mã và thấy rằng nó thực hiện một số thao tác đọc và ghi từ trên một cấu trúc CPU nhất quán về tuần tự, bạn biết rằng mã sẽ thực hiện các lượt đọc và ghi đó theo thứ tự dự kiến. Có thể CPU thực sự đang sắp xếp lại các lệnh và trì hoãn việc đọc và ghi, nhưng không có cách nào để mã chạy trên thiết bị cho biết CPU đang làm gì khác ngoài việc thực thi các lệnh theo cách đơn giản. (Chúng tôi sẽ bỏ qua I/O trình điều khiển thiết bị được ánh xạ bộ nhớ.)
Để minh hoạ những điểm này, bạn nên xem xét các đoạn mã nhỏ, thường được gọi là xét nghiệm quỳ tím.
Dưới đây là ví dụ đơn giản, với mã chạy trên 2 luồng:
Luồng 1 | Chuỗi 2 |
---|---|
A = 3 |
reg0 = B |
Trong ví dụ này và tất cả các ví dụ về quảng cáo địa phương trong tương lai, vị trí bộ nhớ được biểu thị bằng chữ cái viết hoa (A, B, C) và thanh ghi CPU bắt đầu bằng "reg". Tất cả kỷ niệm đều là ban đầu là 0. Các hướng dẫn sẽ được thực thi từ trên xuống dưới. Tại đây, luồng 1 lưu trữ giá trị 3 tại vị trí A, sau đó lưu trữ giá trị 5 tại vị trí B. Chuỗi 2 tải giá trị từ vị trí B vào reg0 rồi tải giá trị từ vị trí A thành reg1. (Lưu ý rằng chúng ta viết theo một thứ tự và sẽ đọc bằng khác).
Luồng 1 và luồng 2 được giả định là sẽ thực thi trên nhiều lõi CPU. Bạn luôn phải giả định điều này khi nghĩ về mã đa luồng.
Tính nhất quán tuần tự đảm bảo rằng sau khi cả hai luồng đã kết thúc thì các thanh ghi sẽ ở một trong các trạng thái sau:
Đăng ký | Các trạng thái |
---|---|
reg0=5, reg1=3 | có thể (luồng 1 chạy trước) |
reg0=0, reg1=0 | có thể (luồng 2 chạy trước tiên) |
reg0=0, reg1=3 | có thể thực hiện (thực thi đồng thời) |
reg0=5, reg1=0 | không bao giờ |
Để gặp phải tình huống mà chúng ta thấy B=5 trước khi thấy cửa hàng đến A, hoặc số lần đọc hoặc ghi sẽ phải xảy ra không đúng thứ tự. Điều đó không thể xảy ra trên một máy có tính tuần tự nhất quán.
Các bộ xử lý đơn nhất (bao gồm cả x86 và ARM) thường thống nhất về tuần tự. Các luồng có vẻ như thực thi theo kiểu xen kẽ, khi nhân hệ điều hành chuyển đổi giữa chúng. Hầu hết các hệ thống SMP, bao gồm cả x86 và ARM, không nhất quán về mặt tuần tự. Ví dụ: thường thì phần cứng sẽ lưu vào bộ đệm các cửa hàng trên đường đến bộ nhớ để các cửa hàng này không truy cập ngay vào bộ nhớ và hiển thị cho các lõi khác.
Các chi tiết có sự khác biệt đáng kể. Ví dụ: x86, mặc dù không tuần tự nhất quán, vẫn đảm bảo rằng reg0 = 5 và reg1 = 0 vẫn là không thể. Các cửa hàng được lưu vào bộ đệm nhưng thứ tự của các cửa hàng đó vẫn được duy trì. Mặt khác, ARM thì không. Thứ tự của các cửa hàng lưu vào vùng đệm không duy trì và các cửa hàng có thể không đạt đến tất cả các lõi khác cùng một lúc. Những khác biệt này rất quan trọng đối với các lập trình viên lắp ráp. Tuy nhiên, như chúng ta sẽ thấy dưới đây, các lập trình viên C, C++ hoặc Java có thể và nên lập trình theo cách che giấu những khác biệt về cấu trúc như vậy.
Cho đến nay, chúng ta đã giả định không thực tế rằng chỉ phần cứng mới sắp xếp lại các lệnh. Trên thực tế, trình biên dịch cũng sắp xếp lại các lệnh thành cải thiện hiệu suất. Trong ví dụ của chúng ta, trình biên dịch có thể quyết định rằng một số mã trong Luồng 2 cần giá trị của reg1 trước khi cần reg0, và do đó tải Hãy đăng ký lại trước. Hoặc một số mã trước đó có thể đã tải A và trình biên dịch có thể quyết định sử dụng lại giá trị đó thay vì tải lại A. Trong cả hai trường hợp, các lượt tải vào reg0 và reg1 có thể được sắp xếp lại.
Bạn được phép sắp xếp lại các quyền truy cập vào các vị trí bộ nhớ khác nhau, trong phần cứng hoặc trong trình biên dịch, vì việc này không ảnh hưởng đến việc thực thi một luồng và có thể cải thiện đáng kể hiệu suất. Như chúng ta sẽ thấy, hãy cẩn thận một chút, chúng tôi cũng có thể ngăn chặn điều này ảnh hưởng đến kết quả của các chương trình đa luồng.
Vì trình biên dịch cũng có thể sắp xếp lại thứ tự các quyền truy cập bộ nhớ, nên vấn đề này không còn mới mẻ đối với các SMP. Ngay cả trên một đơn xử lý, trình biên dịch có thể sắp xếp lại các lượt tải đến reg0 và reg1 trong ví dụ của chúng ta, đồng thời Luồng 1 có thể được lên lịch giữa các lệnh được sắp xếp lại. Nhưng nếu trình biên dịch của chúng ta không sắp xếp lại thứ tự, chúng ta có thể chưa bao giờ quan sát vấn đề này. Trên hầu hết các SMP ARM, ngay cả khi không có trình biên dịch thì việc sắp xếp lại thứ tự đó có thể được nhận thấy, có thể sau khi số lần thực thi thành công. Trừ phi bạn đang lập trình hợp thành nói chung, SMP thường giúp bạn có nhiều khả năng gặp phải vấn đề luôn ở đó.
Lập trình không có tình trạng đua dữ liệu
May mắn thay, thường có một cách dễ dàng để tránh nghĩ về bất kỳ những chi tiết này. Nếu bạn tuân theo một số quy tắc đơn giản, cách này thường an toàn để quên tất cả phần trước đó, ngoại trừ "tính nhất quán tuần tự" phần. Thật không may, các chức năng khác có thể hiển thị nếu bạn vô tình vi phạm các quy tắc đó.
Các ngôn ngữ lập trình hiện đại khuyến khích kiểu lập trình "không có tình trạng tranh giành dữ liệu". Miễn là bạn hứa không giới thiệu "data race" (chạy đua dữ liệu) và tránh một số cấu trúc cho trình biên dịch biết điều ngược lại, trình biên dịch và phần cứng sẽ hứa cung cấp kết quả nhất quán theo trình tự. Điều này không thực sự có nghĩa là các hàm này tránh việc sắp xếp lại quyền truy cập bộ nhớ. Điều này có nghĩa là nếu bạn làm theo các quy tắc, bạn sẽ không thể biết rằng các lượt truy cập vào bộ nhớ đang được sắp xếp lại. Điều này rất giống với việc nói với bạn rằng xúc xích là món ngon và món ăn ngon miệng, miễn là bạn hứa không ghé thăm nhà máy xúc xích. Cuộc đua dữ liệu là thứ phơi bày sự thật xấu xa về bộ nhớ đang đặt hàng lại.
"Cuộc đua dữ liệu" là gì?
Chạy đua dữ liệu xảy ra khi ít nhất hai luồng truy cập đồng thời vào cùng một dữ liệu thông thường và ít nhất một trong số đó sửa đổi dữ liệu đó. Theo "ordinary" hơn" chúng tôi muốn nói đến một điều gì đó không cụ thể là đối tượng đồng bộ hoá để giao tiếp theo chuỗi. Disabledx, biến điều kiện, Java chất bay hơi hoặc đối tượng nguyên tử C++ không phải là dữ liệu thông thường và quyền truy cập của chúng được phép tranh cử. Trên thực tế, chúng được dùng để ngăn chặn việc đua dữ liệu trên .
Để xác định xem hai luồng có đồng thời truy cập vào cùng một vị trí bộ nhớ hay không, chúng ta có thể bỏ qua phần thảo luận về việc sắp xếp lại bộ nhớ ở trên và giả định tính nhất quán tuần tự. Chương trình sau không có cuộc đua dữ liệu
nếu A
và B
là các biến boolean thông thường
sai ban đầu:
Chuỗi 1 | Chuỗi 2 |
---|---|
if (A) B = true |
if (B) A = true |
Vì các toán tử không được sắp xếp lại, nên cả hai điều kiện đều sẽ được đánh giá là false và
không có biến nào được cập nhật. Do đó, không thể tạo ra một cuộc đua dữ liệu. Bạn không cần phải suy nghĩ về điều gì có thể xảy ra nếu tải từ A
và lưu trữ vào B
trong Luồng 1 được sắp xếp lại theo cách nào đó. Trình biên dịch không được phép sắp xếp lại Thread
1 bằng cách viết lại thành "B = true; if (!A) B = false
". Đó chính là
chẳng hạn như làm xúc xích ở giữa thị trấn vào ban ngày.
Cuộc đua dữ liệu được xác định chính thức trên các kiểu tích hợp cơ bản như số nguyên và
tham chiếu hoặc con trỏ. Đang gán cho một int
đồng thời
việc đọc nó trong một chuỗi khác rõ ràng là một cuộc đua dữ liệu. Nhưng cả C++
thư viện chuẩn và
các thư viện Bộ sưu tập Java được viết để
bạn cũng có thể giải thích về
dữ liệu khác nhau ở cấp thư viện. Họ hứa hẹn sẽ không thực hiện chạy đua dữ liệu
trừ phi có quyền truy cập đồng thời vào cùng một vùng chứa, thì ít nhất một trong
để cập nhật thông tin đó. Cập nhật set<T>
trong một chuỗi trong khi
đọc đồng thời trong một ứng dụng khác cho phép thư viện giới thiệu một
cuộc đua dữ liệu và do đó có thể được hiểu theo cách không chính thức là "cuộc đua dữ liệu cấp thư viện".
Ngược lại, cập nhật một set<T>
trong một chuỗi trong khi đọc
một phân khúc khác với nhau, sẽ không dẫn đến cuộc đua dữ liệu, bởi vì
thư viện hứa hẹn sẽ không thực hiện chạy đua dữ liệu (cấp thấp) trong trường hợp đó.
Thông thường, có quyền truy cập đồng thời vào các trường khác nhau trong một cấu trúc dữ liệu không thể thực hiện chạy dữ liệu. Tuy nhiên, có một ngoại lệ quan trọng đối với quy tắc này: Các chuỗi trường bit liền kề trong C hoặc C++ được coi là một "vị trí bộ nhớ". Truy cập vào bất kỳ trường bit nào trong một trình tự như vậy được coi là truy cập vào tất cả các báo cáo đó nhằm mục đích xác định sự tồn tại của một cuộc đua dữ liệu. Điều này phản ánh việc phần cứng thông thường không có khả năng để cập nhật từng bit riêng lẻ mà không cần đọc và ghi lại các bit liền kề. Các lập trình viên Java không có mối lo ngại tương tự nào.
Tránh chạy đua dữ liệu
Các ngôn ngữ lập trình hiện đại cung cấp nhiều tính năng đồng bộ hoá để tránh chạy đua dữ liệu. Sau đây là những công cụ cơ bản nhất:
- Khoá hoặc Disabledx Bạn có thể sử dụng
- Mutex (C++11
std::mutex
hoặcpthread_mutex_t
) hoặc khốisynchronized
trong Java để đảm bảo rằng một số phần mã nhất định không chạy đồng thời với các phần mã khác truy cập vào cùng một dữ liệu. Chúng tôi sẽ đề cập chung đến các cơ sở này và các cơ sở tương tự khác là "khoá". Luôn có được một phương thức khoá cụ thể trước khi dùng chung cấu trúc dữ liệu và giải phóng cấu trúc này sau đó, ngăn chặn tình trạng chạy đua dữ liệu khi truy cập cấu trúc dữ liệu. Phương thức này cũng đảm bảo rằng các bản cập nhật và quyền truy cập là nguyên tử, tức là không có bản cập nhật nào khác cho cấu trúc dữ liệu có thể chạy ở giữa. Đây là công cụ phổ biến nhất từ trước đến nay để ngăn chặn tình trạng đua dữ liệu. Sử dụng Javasynchronized
khối hoặc C++lock_guard
hoặcunique_lock
đảm bảo bạn thả khoá đúng cách trong trường hợp ngoại lệ. - Biến dễ bay hơi/nguyên tử
- Java cung cấp các trường
volatile
hỗ trợ quyền truy cập đồng thời mà không gây ra tình trạng xung đột dữ liệu. Kể từ năm 2011, C và C++ đã hỗ trợ Các biến và trườngatomic
có ngữ nghĩa tương tự. Đây là thường khó sử dụng hơn khoá, vì chúng chỉ đảm bảo rằng truy cập riêng lẻ vào một biến duy nhất mang tính nguyên tử. (Trong C++, thư này thường mở rộng đến các thao tác đọc-sửa đổi-ghi đơn giản, như gia số. Java cần có lệnh gọi phương thức đặc biệt cho việc đó.) Không giống như khoá, biếnvolatile
hoặcatomic
không thể được dùng trực tiếp để ngăn các luồng khác can thiệp vào trình tự mã dài hơn.
Điều quan trọng cần lưu ý là volatile
có ý nghĩa rất khác nhau trong C++ và Java. Trong C++, volatile
không ngăn chặn dữ liệu
mặc dù mã cũ thường sử dụng nó như một giải pháp cho việc thiếu
Đối tượng atomic
. Bạn không nên sử dụng nội dung này nữa; inch
C++, hãy sử dụng atomic<T>
cho các biến có thể đồng thời
được nhiều chuỗi truy cập. volatile
trong C++ dành cho
thanh ghi thiết bị và những thứ tương tự.
Bạn có thể sử dụng biến atomic
C/C++ hoặc biến volatile
Java để ngăn chặn tình trạng xung đột dữ liệu trên các biến khác. Nếu flag
là
khai báo có loại atomic<bool>
hoặc atomic_bool
(C/C++) hoặc volatile boolean
(Java),
và ban đầu là false thì đoạn mã sau đây sẽ là data-race-free:
Chuỗi 1 | Luồng 2 |
---|---|
A = ...
|
while (!flag) {}
|
Vì Luồng 2 chờ flag
được thiết lập, nên quyền truy cập vào
A
trong Luồng 2 phải xảy ra sau và không đồng thời với,
giao cho A
trong Chuỗi 1. Do đó, không có cuộc đua dữ liệu nào trên
A
. Cuộc đua vào flag
không được tính là một cuộc đua dữ liệu,
vì các quyền truy cập biến động/nguyên tử không phải là "quyền truy cập bộ nhớ thông thường".
Cần triển khai để ngăn chặn hoặc ẩn việc sắp xếp lại bộ nhớ đủ để khiến mã như phép kiểm thử quỳ trước hoạt động như mong đợi. Điều này thường khiến các hoạt động truy cập bộ nhớ biến động/nguyên tử tốn kém hơn đáng kể so với các hoạt động truy cập thông thường.
Mặc dù ví dụ trước không có tình trạng đua dữ liệu, nhưng các khoá cùng với Object.wait()
trong Java hoặc các biến điều kiện trong C/C++ thường cung cấp một giải pháp tốt hơn mà không cần chờ trong vòng lặp trong khi tiêu hao pin.
Khi tính năng sắp xếp lại bộ nhớ hiển thị
Việc lập trình không có cuộc đua dữ liệu thường giúp chúng ta không phải giải quyết một cách rõ ràng gặp phải sự cố khi sắp xếp lại quyền truy cập bộ nhớ. Tuy nhiên, có một số trường hợp mà việc sắp xếp lại sẽ hiển thị:- Nếu chương trình của bạn có lỗi dẫn đến tình trạng tranh dữ liệu ngoài ý muốn, thì trình biên dịch và các phép biến đổi phần cứng có thể hiển thị và hành vi của chương trình có thể gây ngạc nhiên. Ví dụ: nếu chúng tôi quên khai báo
flag
biến động trong ví dụ trước, Luồng 2 có thể thấy chưa khởi tạoA
. Hoặc trình biên dịch có thể quyết định rằng cờ không thể có thể thay đổi trong vòng lặp của Thread 2 và biến đổi chương trình thànhLuồng 1 Chuỗi 2 A = ...
flag = truereg0 = cờ; while (!reg0) {}
... = Aflag
là đúng. - C++ cung cấp các cơ sở để nới lỏng rõ ràng tính nhất quán tuần tự ngay cả khi không có tình trạng tranh chấp. Vận hành nguyên tử
có thể nhận các đối số
memory_order_
... tường minh. Tương tự, Góijava.util.concurrent.atomic
cung cấp tính năng nhiều cơ sở vật chất tương tự nhau, đáng chú ý làlazySet()
. Và Java các lập trình viên đôi khi sử dụng các cuộc đua dữ liệu có chủ đích để đạt được hiệu quả tương tự. Tất cả những yếu tố này đều cải thiện hiệu suất đồng thời độ phức tạp của việc lập trình. Chúng ta chỉ thảo luận ngắn gọn về các loại này dưới đây. - Một số mã C và C++ được viết theo kiểu cũ, không hoàn toàn thống nhất với các tiêu chuẩn ngôn ngữ hiện tại, trong đó các biến
volatile
được sử dụng thay vì các biếnatomic
và thứ tự bộ nhớ bị cấm rõ ràng bằng cách chèn các hàng rào hoặc rào cản. Bạn phải có lý do rõ ràng về quyền truy cập sắp xếp lại thứ tự cũng như tìm hiểu về các mô hình bộ nhớ phần cứng. Kiểu lập trình dọc theo các dòng này vẫn được sử dụng trong nhân Linux. Không nên được dùng trong các ứng dụng Android mới và cũng không được thảo luận thêm ở đây.
Thực hành
Việc gỡ lỗi các vấn đề về tính nhất quán của bộ nhớ có thể rất khó khăn. Nếu thiếu
nguyên nhân khai báo khoá, atomic
hoặc volatile
một số mã để đọc dữ liệu cũ, bạn có thể không
tìm hiểu lý do bằng cách kiểm tra vùng nhớ kết xuất bộ nhớ bằng trình gỡ lỗi. Vào thời điểm bạn có thể
phát hành một truy vấn trình gỡ lỗi, lõi CPU có thể đã ghi nhận toàn bộ tập hợp
truy cập và nội dung của bộ nhớ và thanh ghi CPU sẽ nằm trong
trạng thái "không thể".
Việc không nên làm trong C
Ở đây, chúng tôi đưa ra một số ví dụ về mã không chính xác cùng với các cách đơn giản để khắc phục chúng. Trước khi làm việc đó, chúng ta cần thảo luận về cách sử dụng ngôn ngữ cơ bản của chúng tôi.
C/C++ và "volatile"
Nội dung khai báo volatile
của C và C++ là một công cụ có mục đích rất đặc biệt.
Chúng ngăn trình biên dịch sắp xếp lại hoặc xoá volatile
truy cập. Điều này có thể hữu ích cho việc truy cập mã truy cập vào thanh ghi thiết bị phần cứng,
bộ nhớ được ánh xạ tới nhiều vị trí hoặc có liên quan đến
setjmp
. Tuy nhiên, volatile
của C và C++, không giống như volatile
của Java, không được thiết kế để giao tiếp luồng.
Trong C và C++, quyền truy cập vào volatile
có thể được sắp xếp lại thứ tự bằng cách truy cập vào dữ liệu bất biến và
sự đảm bảo về tính nguyên tử. Do đó, bạn không thể sử dụng volatile
để chia sẻ dữ liệu giữa
các luồng trong mã di động, ngay cả trên bộ đơn xử lý. C volatile
thường không ngăn phần cứng sắp xếp lại quyền truy cập, do đó, bản thân nó thậm chí còn ít hữu ích hơn trong môi trường SMP nhiều luồng. Đây là lý do C11 và C++11 hỗ trợ
Đối tượng atomic
. Thay vào đó, bạn nên sử dụng các mã đó.
Nhiều mã C và C++ cũ vẫn sử dụng sai volatile
để giao tiếp luồng. Điều này thường hoạt động chính xác đối với dữ liệu phù hợp với
trong một thanh ghi máy, miễn là có hàng rào rõ ràng hoặc trong các trường hợp
trong đó thứ tự bộ nhớ không quan trọng. Tuy nhiên, phương pháp này không đảm bảo sẽ mang lại hiệu quả
chính xác với các trình biên dịch trong tương lai.
Ví dụ
Trong hầu hết các trường hợp, bạn nên dùng một khoá (chẳng hạn như
pthread_mutex_t
hoặc C++11 std::mutex
) thay vì một
hoạt động nguyên tử, nhưng chúng tôi sẽ sử dụng
phương pháp sau để minh hoạ cách chúng sẽ
được sử dụng trong tình huống thực tế.
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
Ý tưởng ở đây là chúng ta phân bổ một cấu trúc, khởi tạo các trường của cấu trúc và cuối cùng chúng tôi "xuất bản" tệp đó bằng cách lưu trữ tệp đó trong một biến toàn cục. Vào thời điểm đó, bất kỳ luồng nào khác cũng có thể thấy luồng này, nhưng không sao vì nó được khởi chạy hoàn toàn, đúng không?
Vấn đề là có thể quan sát thấy cửa hàng của gGlobalThing
trước khi các trường này được khởi tạo, thường là do trình biên dịch hoặc
đơn vị xử lý đã sắp xếp lại cửa hàng thành gGlobalThing
và
thing->x
. Một chuỗi khác đọc từ thing->x
có thể
thấy 5, 0 hoặc thậm chí dữ liệu chưa khởi tạo.
Vấn đề cốt lõi ở đây là một cuộc đua dữ liệu trên gGlobalThing
.
Nếu Luồng 1 gọi initGlobalThing()
trong khi Luồng 2 gọi
cuộc gọi useGlobalThing()
, gGlobalThing
có thể là
đọc trong khi được viết.
Bạn có thể khắc phục vấn đề này bằng cách khai báo gGlobalThing
là
nguyên tử. Trong C++11:
atomic<MyThing*> gGlobalThing(NULL);
Điều này đảm bảo hoạt động ghi sẽ hiển thị với các luồng khác
theo thứ tự thích hợp. Việc này cũng đảm bảo ngăn chặn một số lỗi khác
được phép, nhưng khó có thể xảy ra trên thực tế
Phần cứng Android. Chẳng hạn, mã này đảm bảo rằng chúng ta không thể thấy
Con trỏ gGlobalThing
chỉ được ghi một phần.
Những việc không nên làm trong Java
Do chưa thảo luận về một số tính năng ngôn ngữ Java có liên quan, nên chúng tôi sẽ lấy một hãy xem nhanh các sản phẩm đó trước.
Về mặt kỹ thuật, Java không yêu cầu mã phải không bị theo dõi dữ liệu (data-race). Và đó là một số ít mã Java được viết rất cẩn thận và hoạt động chính xác khi đứng trước các cuộc đua dữ liệu. Tuy nhiên, việc viết mã như vậy cực kỳ khó khăn và chúng ta chỉ thảo luận ngắn gọn về mã đó ở bên dưới. Quan trọng tệ hơn nữa, các chuyên gia xác định ý nghĩa của đoạn mã này không còn tin rằng quy cách là chính xác. (Thông số kỹ thuật phù hợp với chế độ data-race-free .)
Hiện tại, chúng ta sẽ tuân thủ mô hình không có tình trạng tranh dữ liệu, trong đó Java về cơ bản cung cấp các đảm bảo giống như C và C++. Xin nhắc lại, ngôn ngữ này cung cấp một số hàm gốc giúp nới lỏng rõ ràng tính nhất quán tuần tự, đáng chú ý là các lệnh gọi lazySet()
và weakCompareAndSet()
trong java.util.concurrent.atomic
.
Cũng giống như C và C++, chúng ta tạm thời sẽ bỏ qua các thành phần này.
"được đồng bộ hoá" của Java và "biến động" từ khóa
Từ khoá "synchronized" cung cấp cơ chế khoá tích hợp sẵn của ngôn ngữ Java. Mỗi đối tượng đều có một "màn hình" liên kết có thể được dùng để cung cấp quyền truy cập loại trừ lẫn nhau. Nếu hai luồng cố gắng "đồng bộ hoá" trên cùng một đối tượng, thì một trong hai luồng sẽ chờ cho đến khi luồng còn lại hoàn tất.
Như chúng tôi đã đề cập ở trên, volatile T
của Java là tương tự của
atomic<T>
của C++11. Quyền truy cập đồng thời vào
Các trường volatile
được phép và việc này không dẫn đến việc đua dữ liệu.
Bỏ qua lazySet()
và cộng sự. và cuộc đua dữ liệu, công việc của máy ảo Java là
đảm bảo rằng kết quả vẫn xuất hiện tuần tự nhất quán.
Cụ thể, nếu luồng 1 ghi vào trường volatile
và
luồng 2 sau đó đọc từ cùng trường đó và thấy đoạn mã mới được viết
thì luồng 2 cũng được đảm bảo xem tất cả các lượt ghi trước đó được thực hiện bởi
chuỗi 1. Về hiệu ứng bộ nhớ, việc ghi vào
biến động tương tự như bản phát hành màn hình và
việc đọc từ một biến động giống như thu nạp màn hình.
Có một điểm khác biệt đáng chú ý so với atomic
của C++:
Nếu chúng ta viết volatile int x;
trong Java, thì x++
giống với x = x + 1
; nó
thực hiện tải nguyên tử, tăng kết quả, sau đó thực hiện giảm tải nguyên tử
của bạn. Không giống như C++, số gia tăng tổng thể không phải là số nguyên tử.
Thay vào đó, toán tử tăng nguyên tử được cung cấp bởi
java.util.concurrent.atomic
.
Ví dụ
Dưới đây là cách triển khai bộ đếm đơn điệu không chính xác: (Lý thuyết và thực hành Java: Quản lý sự biến động).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Giả sử get()
và incr()
được gọi từ nhiều luồng và chúng ta muốn đảm bảo rằng mọi luồng đều thấy số lượng hiện tại khi get()
được gọi. Vấn đề rõ ràng nhất là
mValue++
thực ra là 3 toán tử:
reg = mValue
reg = reg + 1
mValue = reg
Nếu 2 luồng thực thi đồng thời trong incr()
, một trong
bản cập nhật có thể bị mất. Để tăng số lượng nguyên tử, chúng ta cần khai báo
incr()
"được đồng bộ hoá".
Tuy nhiên, tính năng này vẫn gặp lỗi, đặc biệt là trên tính năng SMP. Vẫn còn một cuộc đua dữ liệu,
trong đó get()
có thể truy cập mValue
đồng thời với
incr()
. Trong quy tắc Java, lệnh gọi get()
có thể là
có vẻ được sắp xếp lại so với mã khác. Ví dụ: nếu chúng ta đọc hai
các bộ đếm trong một hàng, kết quả có thể dường như không nhất quán
vì các lệnh gọi get()
mà chúng ta đã sắp xếp lại, theo phần cứng hoặc
trình biên dịch. Chúng ta có thể khắc phục vấn đề này bằng cách khai báo get()
là
đã đồng bộ hoá. Với thay đổi này, mã rõ ràng là chính xác.
Rất tiếc, chúng tôi đã giới thiệu khả năng tranh chấp khoá, điều này có thể cản trở hiệu suất. Thay vì khai báo get()
là
đã đồng bộ hoá, chúng ta có thể khai báo mValue
có trạng thái "volatile". (Lưu ý
incr()
vẫn phải sử dụng synchronize
vì
mValue++
không phải là một toán tử đơn lẻ.)
Điều này cũng tránh được tất cả các cuộc đua dữ liệu, do đó, tính nhất quán tuần tự được duy trì.
incr()
sẽ chậm hơn vì phải giám sát cả tính năng nhập/thoát
và mức hao tổn liên quan đến cửa hàng dễ biến động, nhưng
get()
sẽ nhanh hơn, vì vậy, ngay cả khi không có tranh chấp thì đây là
chiến thắng nếu số lượt đọc nhiều hơn số lượng ghi. (Xem thêm AtomicInteger
để biết cách
xoá khối đã đồng bộ hoá.)
Sau đây là một ví dụ khác có hình thức tương tự như các ví dụ C trước đó:
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
Mã này có cùng vấn đề với mã C, cụ thể là có
một cuộc đua dữ liệu vào sGoodies
. Do đó, bài tập
Có thể quan sát thấy sGoodies = goods
trước khi khởi chạy
trong goods
. Nếu bạn khai báo sGoodies
bằng phần tử
volatile
từ khóa, tính nhất quán tuần tự được khôi phục và mọi thứ sẽ hoạt động
như mong đợi.
Lưu ý rằng chỉ có tham chiếu sGoodies
là tự biến động. Chiến lược phát hành đĩa đơn
có quyền truy cập vào các trường bên trong nó. Sau khi sGoodies
volatile
và thứ tự bộ nhớ được giữ nguyên đúng cách, các trường
Không thể truy cập đồng thời. Câu lệnh z =
sGoodies.x
sẽ thực hiện một tải không ổn định của MyClass.sGoodies
, sau đó là một tải không ổn định của sGoodies.x
. Nếu bạn là người bản địa
tham chiếu MyGoodies localGoods = sGoodies
thì z =
localGoods.x
tiếp theo sẽ không thực hiện bất kỳ tải tự tính nào.
Một thành ngữ phổ biến hơn trong lập trình Java là "khoá kiểm tra hai lần" khét tiếng:
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
Ý tưởng là chúng ta muốn có một thực thể duy nhất của Helper
đối tượng liên kết với bản sao của MyClass
. Chúng ta chỉ phải tạo
nên chúng tôi tạo và trả lại mã đó thông qua một getHelper()
riêng
. Để tránh tình huống tương tranh trong đó 2 luồng tạo thực thể, chúng ta cần phải
đồng bộ hoá việc tạo đối tượng. Tuy nhiên, chúng ta không muốn trả chi phí hao tổn cho khối "đồng bộ hoá" trên mỗi lệnh gọi, vì vậy, chúng ta chỉ thực hiện phần đó nếu helper
hiện rỗng.
Mã này có một cuộc đua dữ liệu trên trường helper
. Có thể
đặt đồng thời với helper == null
trong một chuỗi khác.
Để xem cách mã này có thể không hoạt động, hãy xem xét cùng một mã được viết lại một chút, như thể mã này được biên dịch thành một ngôn ngữ giống C (tôi đã thêm một vài trường số nguyên để biểu thị hoạt động của hàm khởi tạo Helper’s
):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
Không có gì có thể ngăn chặn phần cứng hoặc trình biên dịch
từ việc sắp xếp lại cửa hàng sang helper
cùng với các lượt chuyển đổi sang
x
/y
trường. Một chuỗi khác có thể tìm thấy
helper
không có giá trị rỗng nhưng các trường chưa được thiết lập và sẵn sàng để sử dụng.
Để biết thêm thông tin chi tiết và các chế độ lỗi khác, hãy xem phần “"Kiểm tra kép"
Đường liên kết đến phần Khai báo "Khoá bị hỏng" trong phần phụ lục để biết thêm thông tin chi tiết, hoặc
71 (“Sử dụng khởi động từng phần một cách thận trọng”) trong Java hiệu quả của Josh Bloch,
Phiên bản thứ 2..
Có hai cách để khắc phục vấn đề này:
- Hãy làm việc đơn giản và xoá phần kiểm tra bên ngoài. Điều này giúp đảm bảo rằng chúng tôi sẽ không bao giờ
kiểm tra giá trị của
helper
bên ngoài một khối đã đồng bộ hoá. - Khai báo
helper
biến động. Với một thay đổi nhỏ này, mã trong Ví dụ J-3 sẽ hoạt động chính xác trên Java 1.5 trở lên. (Bạn nên lấy để tự thuyết phục rằng đây là sự thật).
Dưới đây là một hình minh hoạ khác về hành vi của volatile
:
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
Nhìn vào useValues()
, nếu Luồng 2 chưa quan sát thấy
cập nhật lên vol1
, thì hệ thống sẽ không thể biết liệu data1
hay
data2
đã được đặt. Sau khi thấy nội dung cập nhật lên
vol1
, ứng dụng biết rằng bạn có thể truy cập an toàn vào data1
và đọc chính xác mà không cần tạo ra một cuộc đua dữ liệu. Tuy nhiên,
không thể đưa ra bất kỳ giả định nào về data2
, vì cửa hàng đó
được thực hiện sau khi cửa hàng biến động.
Lưu ý không thể dùng volatile
để ngăn việc sắp xếp lại thứ tự
của các quyền truy cập bộ nhớ khác chạy đua với nhau. Chúng tôi không đảm bảo
tạo một lệnh hàng rào bộ nhớ máy. Có thể dùng mã này để ngăn
dữ liệu sẽ chạy bằng cách chỉ thực thi mã khi một luồng khác đáp ứng
điều kiện nhất định.
Việc nên làm
Trong C/C++, hãy ưu tiên C++11
các lớp đồng bộ hoá, chẳng hạn như std::mutex
. Nếu không, hãy sử dụng
các toán tử pthread
tương ứng.
Những cách này bao gồm hàng rào bộ nhớ thích hợp, cung cấp kết quả chính xác (tuần tự nhất quán
trừ khi có quy định khác)
và hành vi hiệu quả trên tất cả phiên bản nền tảng Android. Hãy sử dụng các thành phần đó
chính xác. Ví dụ: hãy nhớ rằng biến điều kiện chờ có thể được gây ra một cách ngẫu nhiên
trả về mà không được báo hiệu và do đó sẽ xuất hiện trong một vòng lặp.
Tốt nhất là nên tránh sử dụng trực tiếp các hàm nguyên tử, trừ phi cấu trúc dữ liệu mà bạn đang triển khai cực kỳ đơn giản, như một bộ đếm. Việc khoá và mở khoá một mutex pthread yêu cầu mỗi thao tác nguyên tử và thường tốn ít chi phí hơn một lần thiếu bộ nhớ đệm nếu không có tranh chấp. Vì vậy, bạn sẽ không tiết kiệm được nhiều bằng cách thay thế các lệnh gọi mutex bằng thao tác nguyên tử. Yêu cầu thiết kế không khoá cho các cấu trúc dữ liệu không quan trọng cẩn thận hơn nhiều để đảm bảo rằng thao tác cấp cao hơn trên cấu trúc dữ liệu có vẻ như nguyên tử (là tổng thể, không chỉ là các mảnh rõ ràng ở dạng nguyên tử của chúng).
Nếu bạn sử dụng các thao tác nguyên tử, việc nới lỏng thứ tự bằng memory_order
... hoặc lazySet()
có thể mang lại lợi thế về hiệu suất, nhưng đòi hỏi bạn phải hiểu rõ hơn những gì chúng tôi đã truyền đạt cho đến nay.
Một phần lớn mã hiện tại đang sử dụng
những quảng cáo này được phát hiện có lỗi sau khi thực tế. Hãy tránh những cách này nếu có thể.
Nếu trường hợp sử dụng của bạn không hoàn toàn phù hợp với một trong những trường hợp ở phần tiếp theo,
đảm bảo bạn là chuyên gia hoặc đã tham khảo ý kiến của một chuyên gia.
Tránh sử dụng volatile
để giao tiếp theo luồng trong C/C++.
Trong Java, tốt nhất là các bài toán đồng thời được giải quyết bằng
bằng cách sử dụng một lớp tiện ích thích hợp từ
gói java.util.concurrent
. Mã được viết và kiểm thử kỹ trên SMP.
Có lẽ điều an toàn nhất bạn có thể làm là làm cho các đối tượng của bạn bất biến. Đồ vật từ các lớp như dữ liệu giữ Chuỗi và Số nguyên của Java mà không thể thay đổi được sau khi đối tượng được tạo, tránh mọi khả năng xảy ra các cuộc đua dữ liệu trên các đối tượng đó. Cuốn sách Có hiệu lực Java, Ấn bản thứ 2 có hướng dẫn cụ thể trong "Mục 15: Giảm thiểu khả năng biến đổi". Ghi chú trong cụ thể tầm quan trọng của việc khai báo các trường Java là "cuối cùng" (Bloch).
Ngay cả khi một đối tượng là không thể thay đổi, hãy nhớ rằng việc truyền đạt đối tượng đó với đối tượng khác
mà không có bất kỳ loại đồng bộ hoá nào là một cuộc đua dữ liệu. Điều này đôi khi có thể
có thể chấp nhận được trong Java (xem bên dưới), nhưng đòi hỏi phải hết sức cẩn trọng và có thể dẫn đến
mã dễ gây gián đoạn. Nếu điều đó không quá quan trọng về hiệu suất, hãy thêm
Khai báo volatile
. Trong C++, việc giao tiếp con trỏ hoặc tham chiếu đến một đối tượng không thể thay đổi mà không có tính năng đồng bộ hoá thích hợp, giống như mọi cuộc đua dữ liệu, đều là lỗi.
Trong trường hợp này, khả năng hợp lý là dẫn đến sự cố gián đoạn vì
ví dụ: luồng nhận có thể thấy một bảng phương thức chưa khởi tạo
con trỏ do sắp xếp lại cửa hàng.
Nếu không có lớp thư viện hiện có hoặc lớp không thể thay đổi
phù hợp, câu lệnh Java synchronized
hoặc C++
lock_guard
/ unique_lock
nên được dùng để bảo vệ
quyền truy cập vào bất kỳ trường nào mà nhiều luồng có thể truy cập. Nếu mutex không
phù hợp với tình huống của bạn, bạn nên khai báo các trường dùng chung
volatile
hoặc atomic
, nhưng bạn phải thật cẩn thận
hiểu tương tác giữa các luồng. Những nội dung khai báo này sẽ không
giúp bạn tránh khỏi các lỗi lập trình đồng thời phổ biến, mà chúng sẽ giúp bạn
tránh những lỗi bí ẩn liên quan đến việc tối ưu hoá trình biên dịch và SMP
rủi ro.
Bạn nên tránh "phát hành" tệp tham chiếu đến một đối tượng, tức là cung cấp tệp tham chiếu đó cho các luồng khác trong hàm khởi tạo. Điều này ít quan trọng hơn trong C++ hoặc nếu bạn tiếp tục "cuộc đua dữ liệu không" của chúng tôi trong Java. Tuy nhiên, đây luôn là lời khuyên hữu ích, nếu mã Java của bạn chạy trong các ngữ cảnh khác mà mô hình bảo mật Java quan trọng và không đáng tin cậy có thể tạo ra một cuộc đua dữ liệu bằng cách truy cập vào dữ liệu "bị rò rỉ" đó tham chiếu đối tượng. Điều cũng rất quan trọng nếu bạn chọn bỏ qua cảnh báo của chúng tôi và sử dụng một số kỹ thuật trong phần tiếp theo. Xem (Kỹ thuật xây dựng an toàn trong Java) để biết chi tiết
Thông tin khác về đơn đặt hàng bộ nhớ yếu
C++11 trở lên cung cấp cơ chế rõ ràng để nới lỏng trình tự
đảm bảo tính nhất quán cho các chương trình không có cuộc đua dữ liệu. Ca từ phản cảm
memory_order_relaxed
, memory_order_acquire
(tải
và memory_order_release
(chỉ lưu trữ) đối số cho nguyên tử
mỗi hoạt động đều đảm bảo hiệu quả hơn so với mặc định, thường là
ngầm ẩn, memory_order_seq_cst
. memory_order_acq_rel
cung cấp cả memory_order_acquire
và
memory_order_release
đảm bảo ghi sửa đổi đọc nguyên tử
các toán tử. memory_order_consume
là chưa đủ
được chỉ định hoặc triển khai rõ ràng để trở nên hữu ích và tạm thời nên được bỏ qua.
Các phương thức lazySet
trong Java.util.concurrent.atomic
tương tự như các cửa hàng C++ memory_order_release
. Các biến thông thường của Java đôi khi được dùng để thay thế cho các quyền truy cập memory_order_relaxed
, mặc dù thực tế chúng còn yếu hơn. Không giống như C++, không có cơ chế thực sự nào để không theo thứ tự
truy cập vào các biến được khai báo là volatile
.
Thường thì bạn nên tránh những chế độ này trừ phi có lý do cấp bách về hiệu suất sử dụng chúng. Trên các kiến trúc máy được sắp xếp không theo thứ tự như ARM, việc sử dụng các kiến trúc này sẽ thường được lưu theo thứ tự khoảng vài chục chu kỳ máy cho mỗi hoạt động ở nguyên tử. Trên x86, hiệu suất tăng chỉ giới hạn ở các cửa hàng và có thể ít đáng chú ý hơn. Có vẻ như trái ngược với trực giác, lợi ích có thể giảm khi số lượng lõi lớn hơn, vì hệ thống bộ nhớ trở thành một yếu tố hạn chế hơn.
Ngữ nghĩa đầy đủ của các nguyên tử có thứ tự yếu rất phức tạp. Nhìn chung, các tính năng này yêu cầu hiểu chính xác các quy tắc ngôn ngữ, chúng tôi sẽ không đi vào đây. Ví dụ:
- Trình biên dịch hoặc phần cứng có thể di chuyển
memory_order_relaxed
truy cập vào (nhưng không nằm ngoài) một phần quan trọng được giới hạn bởi khoá thu nạp và phát hành. Điều này có nghĩa là haimemory_order_relaxed
cửa hàng có thể xuất hiện không đúng thứ tự, ngay cả khi chúng được tách riêng bởi một phần quan trọng. - Một biến Java thông thường có thể xuất hiện khi bị lạm dụng làm bộ đếm dùng chung
sang một luồng khác để giảm, mặc dù luồng này chỉ được tăng lên theo một đơn vị
chuỗi khác. Nhưng điều này không đúng đối với nguyên tử C++
memory_order_relaxed
.
Chúng tôi muốn nhắc nhở rằng ở đây, chúng tôi đưa ra một số thành ngữ có vẻ bao hàm nhiều cách sử dụng trường hợp các nguyên tử có thứ tự yếu. Nhiều phần tử trong số này chỉ áp dụng được cho C++.
Đường vào không phải đua xe
Khá phổ biến rằng biến là nguyên tử vì đôi khi
đọc đồng thời với thao tác ghi, nhưng không phải mọi quyền truy cập đều gặp vấn đề này.
Ví dụ: một biến
có thể cần ở tỷ lệ nguyên tử vì nội dung được đọc bên ngoài phần quan trọng, nhưng tất cả
các bản cập nhật được bảo vệ bằng khoá. Trong trường hợp đó, một hoạt động đọc tình cờ được bảo vệ bằng cùng một khoá không thể chạy đua, vì không thể có hoạt động ghi đồng thời. Trong trường hợp như vậy,
quyền truy cập không chạy đua (tải trong trường hợp này), có thể được chú thích bằng
memory_order_relaxed
mà không thay đổi độ chính xác của mã C++.
Quá trình triển khai khoá đã thực thi thứ tự bộ nhớ bắt buộc
đối với quyền truy cập của các chuỗi khác và memory_order_relaxed
chỉ định rằng về cơ bản, không cần phải có ràng buộc bổ sung nào khác về thứ tự
thực thi để truy cập vào nguyên tử.
Không có sự tương đồng thực sự nào trong Java.
Kết quả không được dựa vào tính chính xác
Nếu chúng ta chỉ sử dụng tải dựa trên dữ liệu ngẫu nhiên để tạo gợi ý, thì điều này thường chấp nhận được
không thực thi bất kỳ thứ tự bộ nhớ nào cho tải. Nếu giá trị là
không đáng tin cậy, chúng tôi cũng không thể sử dụng kết quả đó để suy ra bất kỳ điều gì một cách đáng tin cậy
các biến khác. Do đó, không sao
nếu thứ tự bộ nhớ không được đảm bảo và tải là
được cung cấp cùng với một đối số memory_order_relaxed
.
Một trường hợp phổ biến của việc này là sử dụng compare_exchange
C++ để thay thế x
bằng f(x)
một cách nguyên tử.
Tải ban đầu của x
để tính toán f(x)
không cần phải đáng tin cậy. Nếu chúng tôi nhầm lẫn,
compare_exchange
sẽ không thành công và chúng tôi sẽ thử lại.
Bạn có thể sử dụng tải ban đầu của x
một đối số memory_order_relaxed
; chỉ có thứ tự bộ nhớ
cho compare_exchange
thực tế.
Dữ liệu được sửa đổi nguyên tử nhưng chưa đọc
Đôi khi, nhiều luồng cũng sửa đổi song song dữ liệu, nhưng
chưa được kiểm tra cho đến khi việc tính toán song song hoàn tất. Một ví dụ điển hình về điều này là bộ đếm được tăng dần một cách nguyên tử (ví dụ: sử dụng fetch_add()
trong C++ hoặc atomic_fetch_add_explicit()
trong C) bằng nhiều luồng song song, nhưng kết quả của các lệnh gọi này luôn bị bỏ qua. Giá trị thu được chỉ được đọc ở cuối, sau khi tất cả nội dung cập nhật hoàn tất.
Trong trường hợp này, không có cách nào để biết liệu có phải truy cập vào dữ liệu này hay không
được sắp xếp lại, do đó mã C++ có thể sử dụng memory_order_relaxed
đối số.
Một ví dụ phổ biến về trường hợp này là bộ đếm sự kiện đơn giản. Vì rất phổ biến, nên bạn nên quan sát trường hợp này:
- Việc sử dụng
memory_order_relaxed
sẽ cải thiện hiệu suất, nhưng có thể không giải quyết được vấn đề quan trọng nhất về hiệu suất: Mọi bản cập nhật yêu cầu quyền truy cập độc quyền vào dòng bộ nhớ đệm chứa bộ đếm. Điều này dẫn đến việc thiếu bộ nhớ đệm mỗi khi một luồng mới truy cập vào bộ đếm. Nếu cập nhật thường xuyên và luân phiên giữa các luồng, sẽ nhanh hơn nhiều tránh phải cập nhật bộ đếm dùng chung mỗi lần bằng cách ví dụ: sử dụng bộ đếm luồng cục bộ và tổng hợp chúng ở cuối. - Kỹ thuật này có thể kết hợp với phần trước: Có thể
đọc đồng thời giá trị gần đúng và giá trị không đáng tin cậy trong khi cập nhật,
cho tất cả các thao tác sử dụng
memory_order_relaxed
. Nhưng điều quan trọng là phải coi giá trị thu được là hoàn toàn không đáng tin cậy. Chỉ vì số lượng dường như đã tăng lên một lần nhưng không có nghĩa là có thể tính vào một chuỗi khác để đạt đến điểm này mà tại đó bước tăng đã được thực hiện. Thay vào đó, giá trị gia tăng này có thể có được đặt lại bằng mã trước đó. (Đối với trường hợp tương tự, chúng tôi đã đề cập trước đó, C++ đảm bảo rằng lần tải thứ hai của bộ đếm như vậy sẽ không trả về một giá trị nhỏ hơn lần tải trước đó trong cùng một luồng. Trừ trường hợp bộ đếm tràn.) - Thông thường, bạn sẽ thấy mã cố gắng tính toán gần đúng đếm các giá trị bằng cách thực hiện đọc và ghi nguyên tử (hoặc không) riêng lẻ, nhưng không làm tăng mức tăng như một nguyên tử. Đối số thông thường là giá trị này "đủ gần" đối với bộ đếm hiệu suất hoặc các bộ đếm tương tự. Thường thì không phải vậy. Khi quá trình cập nhật đủ thường xuyên (trường hợp mà bạn có thể quan tâm), thì một phần lớn số liệu thường là thua. Trên thiết bị lõi tứ, hơn một nửa số lượng dữ liệu có thể thường bị mất. (Bài tập dễ dàng: tạo một tình huống hai luồng trong đó bộ đếm được cập nhật một triệu lần, nhưng giá trị bộ đếm cuối cùng là một.)
Thông tin giao tiếp đơn giản do cờ
Một cửa hàng memory_order_release
(hoặc thao tác đọc-sửa đổi-ghi)
đảm bảo rằng nếu sau đó một lượt tải memory_order_acquire
(hay thao tác đọc-sửa đổi-ghi) đọc giá trị đã ghi, sau đó nó sẽ
cũng quan sát mọi kho hàng (bình thường hoặc nguyên tử) trước thời điểm
Một cửa hàng ở memory_order_release
. Ngược lại, bất kỳ lượt tải nào
trước memory_order_release
sẽ không nhận thấy bất kỳ
các kho lưu trữ tuân theo tải memory_order_acquire
.
Không giống như memory_order_relaxed
, thao tác này cho phép các thao tác nguyên tử như vậy
được dùng để thông báo tiến trình của một luồng với luồng khác.
Ví dụ: chúng ta có thể viết lại ví dụ về việc khoá kiểm tra hai lần ở trên trong C++ như sau
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
Cửa hàng tải và phát hành thu nạp đảm bảo rằng nếu chúng ta thấy giá trị không rỗng
helper
, thì chúng ta cũng sẽ thấy các trường của lớp này được khởi chạy chính xác.
Chúng tôi cũng đã kết hợp quan sát trước đó rằng tải không phải là chạy đua
có thể sử dụng memory_order_relaxed
.
Một lập trình viên Java có thể hình dung ra là helper
dưới dạng một
java.util.concurrent.atomic.AtomicReference<Helper>
và dùng lazySet()
làm cửa hàng phát hành. Tải trọng
các thao tác sẽ tiếp tục sử dụng các lệnh gọi get()
thuần tuý.
Trong cả hai trường hợp, việc tinh chỉnh hiệu suất của chúng tôi tập trung vào việc khởi chạy mà không có khả năng là yếu tố quan trọng về hiệu suất. Một hình thức xâm phạm dễ đọc hơn có thể là:
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
Lựa chọn này cung cấp cùng một đường dẫn nhanh, nhưng các khu nghỉ dưỡng mặc định, tuần tự nhất quán, các hoạt động trên chậm không quan trọng về hiệu suất đường dẫn.
Ngay cả ở đây, helper.load(memory_order_acquire)
có khả năng tạo ra cùng một mã trên ứng dụng Android hiện được hỗ trợ
dưới dạng một tham chiếu đơn giản (có tính nhất quán tuần tự) đến
helper
. Thực sự, việc tối ưu hoá có lợi nhất ở đây có thể là việc giới thiệu myHelper
để loại bỏ tải thứ hai, mặc dù trình biên dịch trong tương lai có thể tự động thực hiện việc đó.
Việc thêm/phát hành không ngăn cản việc cửa hàng xuất hiện
bị trễ và không đảm bảo rằng cửa hàng sẽ hiển thị với các chuỗi tin nhắn khác
theo thứ tự nhất quán. Do đó, mô hình này không hỗ trợ một mẫu lập trình phức tạp nhưng khá phổ biến, được minh hoạ bằng thuật toán loại trừ lẫn nhau của Dekker: Trước tiên, tất cả các luồng đều đặt một cờ cho biết rằng chúng muốn làm gì đó; nếu một luồng t sau đó nhận thấy không có luồng nào khác đang cố gắng làm gì đó, thì luồng đó có thể tiếp tục một cách an toàn, biết rằng sẽ không có sự can thiệp nào. Sẽ không có chuỗi nào khác
có thể tiếp tục vì cờ của t vẫn được đặt. Không thực hiện được
nếu cờ này được truy cập bằng thứ tự thu nạp/phát hành, vì cách này không
ngăn người khác nhìn thấy cờ của một chuỗi sau khi họ đã
đã xử lý nhầm. memory_order_seq_cst
mặc định
ngăn chặn điều đó.
Các trường không thể thay đổi
Nếu một trường đối tượng được khởi tạo trong lần sử dụng đầu tiên và sau đó không bao giờ thay đổi,
có thể khởi chạy và sau đó đọc mã đó bằng cách sử dụng một cách yếu
lượt truy cập theo thứ tự. Trong C++, giá trị này có thể được khai báo là atomic
và truy cập bằng memory_order_relaxed
hoặc trong Java,
có thể được khai báo mà không cần volatile
và được truy cập mà không cần
các biện pháp đặc biệt. Điều này yêu cầu tất cả các yêu cầu lưu giữ dữ liệu sau đây:
- Phải có thể nhận biết được từ giá trị của chính trường đó liệu đã được khởi tạo hay chưa. Để truy cập vào trường này, giá trị kiểm tra và trả về của đường dẫn nhanh chỉ được đọc trường một lần. Trong Java, thuật ngữ sau là một phần không thể thiếu. Ngay cả khi kiểm tra trường khi khởi tạo, lần tải thứ hai có thể đọc giá trị chưa khởi tạo trước đó. Trong C++ lời nhắc "đọc một lần" quy tắc chỉ là một phương pháp hay.
- Cả quá trình khởi chạy và tải tiếp theo đều phải là nguyên tử,
trong đó, các bản cập nhật một phần sẽ không hiển thị. Đối với Java, trường
không được là
long
hoặcdouble
. Đối với C++, yêu cầu chỉ định nguyên tử; việc xây dựng mã tại chỗ sẽ không hiệu quả, vì cấu trúc củaatomic
không phải là một nguyên tử. - Các quá trình khởi chạy lặp lại phải an toàn vì nhiều luồng có thể đọc đồng thời giá trị chưa khởi tạo. Trong C++, điều này thường là do yêu cầu "có thể sao chép một cách đơn giản" được áp dụng cho tất cả các loại nguyên tử; các loại có con trỏ sở hữu lồng nhau sẽ yêu cầu giải phóng trong hàm khởi tạo sao chép và sẽ không thể sao chép một cách đơn giản. Đối với Java, một số loại tệp tham chiếu nhất định được chấp nhận:
- Tham chiếu Java được giới hạn ở các loại không thể thay đổi chỉ chứa cuối cùng mới. Không được xuất bản hàm khởi tạo thuộc loại không thể thay đổi tham chiếu đến đối tượng. Trong trường hợp này, các quy tắc của trường cuối cùng trong Java đảm bảo rằng nếu độc giả xem tệp tham chiếu, họ cũng sẽ thấy các trường cuối cùng đã khởi tạo. C++ không có điểm tương đồng với các quy tắc này và con trỏ đến các đối tượng được sở hữu cũng không được chấp nhận vì lý do này (trong ngoài việc vi phạm chính sách "có thể sao chép không đáng kể" ).
Ghi chú kết thúc
Mặc dù tài liệu này không chỉ đơn thuần là vết xước, nhưng nó không quản lý nhiều hơn một lỗ hổng quá mức. Đây là một chủ đề rất rộng và sâu. Một số phương diện để khám phá thêm:
- Mô hình bộ nhớ Java và C++ thực tế được biểu thị dưới dạng
Mối quan hệ xảy ra trước chỉ định thời điểm đảm bảo hai hành động
xảy ra theo thứ tự nhất định. Khi xác định một cuộc đua dữ liệu, chúng ta đã nói một cách không chính thức về hai lần truy cập bộ nhớ xảy ra "đồng thời".
Chính thức, điều này được định nghĩa là không có sự kiện nào xảy ra trước sự kiện còn lại.
Hướng dẫn tìm hiểu định nghĩa thực tế về những điều xảy ra trước đây
và đồng bộ hoá-with trong Mô hình bộ nhớ Java hoặc C++.
Mặc dù khái niệm trực quan là "đồng thời" thường tốt
đầy đủ, các định nghĩa này mang tính hướng dẫn, đặc biệt nếu bạn
đang dự tính sử dụng các toán tử nguyên tử có thứ tự yếu trong C++.
(Thông số kỹ thuật Java hiện tại chỉ xác định
lazySet()
rất thân mật.) - Tìm hiểu những trình biên dịch được phép và không được phép làm khi sắp xếp lại mã. (Thông số kỹ thuật JSR-133 có một số ví dụ tiêu biểu về những chuyển đổi pháp lý dẫn đến kết quả không mong muốn).
- Tìm hiểu cách viết các lớp không thay đổi được trong Java và C++. (Và còn nhiều lựa chọn khác thay vì chỉ "không thay đổi bất cứ điều gì sau khi xây dựng".)
- Nội bộ hoá các đề xuất trong phần Đồng thời của bài viết Hiệu quả Java, phiên bản thứ 2. (Ví dụ: bạn nên tránh gọi các phương thức được dùng để ghi đè trong khi ở bên trong một khối đồng bộ hoá.)
- Hãy đọc qua API
java.util.concurrent
vàjava.util.concurrent.atomic
để xem các API có sẵn. Cân nhắc sử dụng các chú giải đồng thời như@ThreadSafe
và@GuardedBy
(từ net.jcip.annotations).
Phần Đọc thêm trong phần phụ lục có đường liên kết đến các tài liệu và trang web sẽ giúp bạn hiểu rõ hơn về những chủ đề này.
Phụ lục
Triển khai các cửa hàng đồng bộ hoá
(Đây không phải là điều mà hầu hết các lập trình viên sẽ tự tìm thấy, nhưng cuộc thảo luận lại mang tính sáng tạo.)
Đối với các loại tích hợp nhỏ như int
và phần cứng được hỗ trợ bởi
Android, các hướng dẫn tải thông thường và hướng dẫn lưu trữ giúp đảm bảo rằng cửa hàng
sẽ được hiển thị toàn bộ hoặc hoàn toàn không hiển thị với người khác
trình xử lý tải cùng một vị trí. Do đó, có một số khái niệm cơ bản
của "tính nguyên tử" được cung cấp miễn phí.
Như chúng ta đã thấy, như vậy là chưa đủ. Để đảm bảo tính nhất quán tuần tự, chúng ta cũng cần ngăn việc sắp xếp lại các thao tác và đảm bảo các thao tác bộ nhớ hiển thị với các quy trình khác theo thứ tự nhất quán. Hoá ra là chế độ cài đặt tự động được Android hỗ trợ phần cứng, miễn là chúng ta đưa ra các lựa chọn sáng suốt để thực thi phần cứng, nên chúng tôi hầu như bỏ qua ở đây.
Thứ tự của các thao tác bộ nhớ được giữ nguyên bằng cách ngăn trình biên dịch sắp xếp lại và ngăn phần cứng sắp xếp lại. Ở đây chúng ta tập trung về phần tiếp theo.
Thứ tự bộ nhớ trên ARMv7, x86 và MIPS được thực thi bằng
"hàng rào" các hướng dẫn
ngăn chặn hoàn toàn những chỉ dẫn đi theo hàng rào hiện ra
trước khi có chỉ dẫn đứng trước hàng rào. (Các lệnh này cũng thường được gọi là lệnh "rào cản", nhưng điều này có thể gây nhầm lẫn với các rào cản kiểu pthread_barrier
, có nhiều chức năng hơn.) Ý nghĩa chính xác của
hướng dẫn về hàng rào là một chủ đề khá phức tạp phải giải quyết
cách thức đảm bảo được cung cấp bởi nhiều loại hàng rào
tương tác và cách những cam kết này kết hợp với
các cam kết đặt hàng khác thường
do phần cứng cung cấp. Đây là thông tin tổng quan, vì vậy chúng tôi sẽ
bóng loáng hơn những chi tiết này.
Phương thức đảm bảo sắp xếp theo thứ tự cơ bản nhất là do C++ cung cấp
memory_order_acquire
và memory_order_release
hoạt động nguyên tử: hoạt động bộ nhớ trước khi lưu trữ bản phát hành
phải hiển thị sau khi tải. Trên ARMv7, việc này được thực thi bằng:
- Thêm hướng dẫn về hàng rào phù hợp vào trước hướng dẫn cửa hàng. Điều này ngăn việc sắp xếp lại tất cả các quyền truy cập bộ nhớ trước đó bằng hướng dẫn cửa hàng. (Điều này cũng không cần thiết ngăn việc sắp xếp lại với hướng dẫn cửa hàng sau này).
- Làm theo hướng dẫn tải cùng với hướng dẫn phù hợp về hàng rào, ngăn không cho tải được sắp xếp lại với các lần truy cập tiếp theo. (Và một lần nữa cung cấp thứ tự không cần thiết ít nhất là tải sớm hơn.)
Kết hợp các yếu tố này là đủ cho thứ tự thu nạp/phát hành C++.
Các chỉ số này cần thiết, nhưng chưa đủ, đối với volatile
của Java
hoặc C++ nhất quán tuần tự atomic
.
Để biết chúng ta cần thêm những gì, hãy xem xét đoạn thuật toán Dekker
mà chúng tôi đã đề cập ngắn gọn trước đó.
flag1
và flag2
là C++ atomic
hoặc Java volatile
, cả hai ban đầu đều có giá trị false.
Chuỗi 1 | Luồng 2 |
---|---|
flag1 = true |
flag2 = true |
Tính nhất quán tuần tự ngụ ý rằng một trong những chỉ định để
flag
n phải được thực thi trước tiên và sẽ hiển thị với
kiểm thử trong luồng khác. Do đó, chúng tôi sẽ không bao giờ thấy
các luồng này thực thi đồng thời "nội dung quan trọng".
Tuy nhiên, tính năng khoanh vùng bắt buộc đối với thứ tự thu nạp bản phát hành chỉ thêm vào
hàng rào ở đầu và cuối mỗi chuỗi, điều này không giúp ích
vào đây. Ngoài ra, chúng tôi cần đảm bảo rằng nếu một
volatile
/atomic
cửa hàng đứng trước
tải volatile
/atomic
, cả hai không được sắp xếp lại.
Điều này thường được thực thi bằng cách thêm một hàng rào không chỉ trước mà còn sau một cửa hàng nhất quán tuần tự.
(Một lần nữa, hàng rào này mạnh hơn nhiều so với yêu cầu, vì hàng rào này thường yêu cầu
tất cả các lượt truy cập bộ nhớ trước đó so với tất cả các lượt truy cập sau này).
Thay vào đó, chúng ta có thể liên kết hàng rào bổ sung với tải nhất quán. Vì cửa hàng ít phổ biến hơn, nên quy ước chúng tôi mô tả phổ biến hơn và được sử dụng trên Android.
Như đã thấy trong phần trước, chúng ta cần chèn rào cản cửa hàng/tải hàng giữa hai phép toán. Mã được thực thi trong máy ảo cho quyền truy cập biến động sẽ có dạng như sau:
tải không ổn định | bộ nhớ biến động |
---|---|
reg = A |
fence for "release" (2) |
Kiến trúc máy thực thường cung cấp nhiều loại hàng rào, có thứ tự các loại quyền truy cập khác nhau và có thể có chi phí khác nhau. Lựa chọn giữa những câu hỏi này rất tinh tế và chịu ảnh hưởng bởi nhu cầu đảm bảo rằng các cửa hàng được hiển thị đến những điểm cốt lõi khác trong thứ tự nhất quán và thứ tự bộ nhớ do kết hợp nhiều hàng rào một cách chính xác. Để biết thêm thông tin, vui lòng xem trang của Đại học Cambridge với thu thập được ánh xạ của các nguyên tử đến bộ xử lý thực tế.
Trên một số kiến trúc, đặc biệt là x86, tính năng "mua lại" và "release" rào cản là không cần thiết vì phần cứng luôn ngầm ẩn thực thi đủ thứ tự. Do đó trên x86 chỉ có hàng rào cuối cùng (3) thực sự được tạo ra. Tương tự như trên x86, atom read-modify-write hoạt động ngầm bao gồm một hàng rào chắc chắn. Do đó, các ứng dụng này không bao giờ yêu cầu hàng rào. Trên ARMv7, tất cả các hàng rào mà chúng ta đã thảo luận ở trên đều là bắt buộc.
ARMv8 cung cấp hướng dẫn LDAR và STLR trực tiếp thực thi các yêu cầu của Java volatile hoặc C++ theo tuần tự nhất quán tải và lưu trữ. Điều này giúp tránh các quy tắc ràng buộc sắp xếp lại không cần thiết mà chúng tôi đã đề cập ở trên. Mã Android 64 bit trên ARM sử dụng các định dạng này; chúng tôi chọn tập trung vào vị trí hàng rào ARMv7 ở đây vì nó làm sáng tỏ nhiều ánh sáng hơn các yêu cầu thực tế.
Tài liệu đọc thêm
Các trang web và tài liệu cung cấp thông tin chuyên sâu hơn. Thông tin thường hữu ích hơn bài viết ở gần đầu danh sách.
- Các mô hình về tính nhất quán của bộ nhớ dùng chung: Hướng dẫn
- Được viết vào năm 1995 bởi Adve & Gharachorloo, đây là nơi phù hợp để bắt đầu nếu bạn muốn tìm hiểu sâu hơn về các mô hình tính nhất quán của bộ nhớ.
http://www.hpl.HP.com/techreports/Compaq-DEC/Wrl-95-7.pdf - Rào cản về bộ nhớ
- Bài viết rất ngắn gọn tóm tắt các vấn đề.
https://vi.wikipedia.org/wiki/Memory_barrier - Kiến thức cơ bản về luồng
- Giới thiệu về lập trình đa luồng trong C++ và Java của Hans Boehm. Nội dung thảo luận về các cuộc đua dữ liệu và các phương pháp đồng bộ hoá cơ bản.
http://www.hboehm.info/c++mm/threadsintro.html - Mô hình đồng thời Java trong thực tế
- Xuất bản năm 2006, cuốn sách này trình bày rất chi tiết về nhiều chủ đề. Rất nên dùng cho những ai viết mã đa luồng trong Java.
http://www.javaconcurrencyinpractice.com - Câu hỏi thường gặp về JSR-133 (Mô hình bộ nhớ Java)
- Giới thiệu sơ bộ về mô hình bộ nhớ Java, bao gồm nội dung giải thích về quá trình đồng bộ hoá, các biến tự tính toán lại và cấu trúc của các trường cuối cùng.
(Hơi lỗi thời, đặc biệt là khi thảo luận về các ngôn ngữ khác.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Tính hợp lệ của các phép biến đổi chương trình trong mô hình bộ nhớ Java
- Giải thích khá kỹ thuật về các vấn đề còn lại của
Mô hình bộ nhớ Java. Những vấn đề này không áp dụng cho cơ chế không có cuộc đua dữ liệu
các chương trình.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Tổng quan về gói java.util.concurrent
- Tài liệu về gói
java.util.concurrent
. Ở gần cuối trang là một phần có tiêu đề "Thuộc tính về tính nhất quán của bộ nhớ", phần này giải thích các đảm bảo do các lớp khác nhau đưa ra.
Tóm tắt về góijava.util.concurrent
- Lý thuyết và thực hành Java: Kỹ thuật xây dựng an toàn trong Java
- Bài viết này xem xét chi tiết các mối nguy hiểm của việc thoát tệp tham chiếu trong quá trình tạo đối tượng, đồng thời cung cấp các nguyên tắc về hàm khởi tạo an toàn cho luồng.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Lý thuyết và thực hành Java: Quản lý sự biến động
- Một bài viết hay mô tả những việc bạn có thể và không thể làm với các trường biến động trong Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - Nội dung khai báo "Khoá được kiểm tra kỹ đã bị hỏng"
- Bill Pugh giải thích chi tiết về các cách phá khoá khi kiểm tra kỹ mà không cần dùng
volatile
hoặcatomic
. Bao gồm C/C++ và Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM] Sách dạy nấu ăn và kiểm thử Litmus của Barrier
- Một cuộc thảo luận về các vấn đề liên quan đến ARM SMP, được trình bày bằng các đoạn mã ngắn của mã ARM. Nếu bạn thấy các ví dụ trên trang này quá không cụ thể hoặc muốn đọc nội dung mô tả chính thức của hướng dẫn DMB, hãy đọc nội dung này. Đồng thời mô tả các hướng dẫn dùng cho rào cản bộ nhớ trên mã thực thi (có thể hữu ích nếu bạn đang tạo mã nhanh chóng). Xin lưu ý rằng phiên bản này ra đời trước ARMv8, phiên bản này cũng hỗ trợ các lệnh sắp xếp bộ nhớ bổ sung và chuyển sang một mô hình bộ nhớ mạnh hơn một chút. (Xem "Hướng dẫn tham khảo kiến trúc ARM® ARMv8, dành cho cấu hình kiến trúc ARMv8-A" để biết thông tin chi tiết.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Rào cản bộ nhớ của nhân Linux
- Tài liệu về rào cản bộ nhớ của nhân Linux. Bao gồm một số ví dụ hữu ích và nghệ thuật ASCII.
http://www.kernel.org/doc/Document/memory-barriers.txt - ISO/IEC JTC1 SC22 WG21 (Tiêu chuẩn C++) 14882 (Ngôn ngữ lập trình C++), mục 1.10 và mệnh đề 29 ("Thư viện thao tác nguyên tử")
- Tiêu chuẩn dự thảo cho các tính năng thao tác ở cấp độ nguyên tử trong C++. Phiên bản này
gần với tiêu chuẩn C++14, bao gồm cả những thay đổi nhỏ trong phần này
từ C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(giới thiệu: http://www.hpl.HP.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14 (Tiêu chuẩn C) 9899 (Ngôn ngữ lập trình C) chương 7.16 (“Nguyên tử <stdatom.h>”)
- Tiêu chuẩn dự thảo cho các tính năng vận hành ở cấp nguyên tử ISO/IEC 9899-201x C.
Để biết thông tin chi tiết, hãy kiểm tra cả các báo cáo sai sót sau này.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - Ánh xạ C/C++11 tới bộ xử lý (Đại học Cambridge)
- Bộ sưu tập bản dịch của Jaroslav Sev Cách và Peter Sewell
của nguyên tử C++ sang tập lệnh phổ biến của bộ xử lý.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Thuật toán Dekker
- "Giải pháp chính xác đầu tiên được biết đến cho bài toán loại trừ lẫn nhau trong lập trình đồng thời". Bài viết wikipedia có thuật toán đầy đủ, kèm theo nội dung thảo luận về cách thuật toán cần được cập nhật để hoạt động với các trình biên dịch tối ưu hoá và phần cứng SMP hiện đại.
https://vi.wikipedia.org/wiki/Dekker's_algorithm - Nhận xét về ARM so với Alpha và các phần phụ thuộc địa chỉ
- Một email về danh sách gửi thư chi tiết từ Catalin Marinas. Bao gồm một bản tóm tắt thú vị về các phần phụ thuộc kiểm soát và địa chỉ.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - Những điều mà mọi lập trình viên cần biết về bộ nhớ
- Bài viết rất dài và chi tiết của Ulrich Drepper về các loại bộ nhớ, đặc biệt là bộ nhớ đệm CPU.
http://www.akkadia.org/drepper/cpumemory.pdf - Lý do về mô hình bộ nhớ ARM yếu
- Bài viết này do Chong & Ishtiaq của ARM, Ltd. Công cụ này cố gắng mô tả mô hình bộ nhớ ARM SMP một cách nghiêm ngặt nhưng dễ tiếp cận. Định nghĩa về "khả năng ghi nhận" được sử dụng ở đây được lấy trong bài viết này. Xin nhắc lại, phiên bản này ra đời trước ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - Sách nấu ăn JSR-133 dành cho người viết trình biên dịch
- Doug Lea viết mã này trong tài liệu về JSR-133 (Mô hình bộ nhớ Java). Tài liệu này có bộ nguyên tắc triển khai ban đầu
cho mô hình bộ nhớ Java được nhiều trình biên dịch sử dụng và là
vẫn được trích dẫn nhiều và có khả năng cung cấp thông tin chi tiết.
Rất tiếc, 4 loại hàng rào được thảo luận ở đây không phải là một lựa chọn tốt
phù hợp với các cấu trúc được Android hỗ trợ và ánh xạ C++11 ở trên
giờ đây là một nguồn công thức chính xác tốt hơn, ngay cả đối với Java.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO: Mô hình lập trình nghiêm ngặt và dễ sử dụng dành cho bộ đa xử lý x86
- Thông tin mô tả chính xác về mô hình bộ nhớ x86. Rất tiếc, nội dung mô tả chính xác về mô hình bộ nhớ ARM phức tạp hơn đáng kể.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf