Sơ lược SMP dành cho Android

Các phiên bản nền tảng Android 3.0 trở lên được tối ưu hoá để hỗ trợ kiến trúc đa bộ 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 các hệ thống đa xử lý đối xứng bằng ngôn ngữ lập trình C, C++ và Java (sau đây gọi tắt là "Java"). Tài liệu này được dùng làm tài liệu hướng dẫn cơ bản cho nhà phát triển ứng dụng Android, chứ không phải là một tài liệu thảo luận đầy đủ về chủ đề này.

Giới thiệu

SMP là từ viết tắt của "Symmetric Multi-Processor" (Đa xử lý đối xứng). Thuật ngữ này mô tả một thiết kế trong đó hai hoặc nhiều lõi CPU giống hệt nhau cùng truy cập vào bộ nhớ chính. Cho đến vài năm trước, tất cả thiết bị Android đều là UP (Uni-Processor, Bộ xử lý đơn).

Hầu hết (nếu không muốn nói là tất cả) thiết bị Android luôn có nhiều CPU, nhưng trước đây chỉ có một CPU được dùng để chạy ứng dụng, trong khi các CPU khác quản lý nhiều phần cứng thiết bị (ví dụ: sóng vô tuyến). Các CPU có thể có cấu trúc khác nhau và các chương trình chạy trên đó không thể sử dụng bộ nhớ chính để giao tiếp với nhau.

Hầu hết các thiết bị Android được bán 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 bộ xử lý đơn, nhưng có thể thường xuyên không thành công 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ể dễ gặp lỗi hơn hoặc ít gặp lỗi hơn khi chạy trên các kiến trúc bộ xử lý khác nhau, hoặc thậm chí trên các cách triển khai khác nhau của cùng một kiến trúc. Mã đã được kiểm thử kỹ lưỡng trên x86 có thể gặp lỗi nghiêm trọng trên ARM. Mã có thể bắt đầu không hoạt động 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 những việc cần làm để đảm bảo mã của bạn hoạt động chính xác.

Mô hình nhất quán về bộ nhớ: Lý do SMP có một chút khác biệt

Đây là thông tin tổng quan nhanh và hấp dẫn về một chủ đề phức tạp. Một số phần sẽ chưa hoàn chỉnh, nhưng không được có phần nào gây hiểu lầm hoặc sai lệch. Như bạn sẽ thấy trong phần tiếp theo, thông tin chi tiết ở đây thường không quan trọng.

Hãy xem phần Tài liệu đọc thêm ở cuối tài liệu để biết thêm thông tin chi tiết về chủ đề này.

Mô hình nhất quán bộ nhớ, hay thường chỉ là "mô hình bộ nhớ", mô tả các đảm bảo mà ngôn ngữ lập trình hoặc cấu trúc phần cứng đưa ra về quyền truy cập bộ nhớ. Ví dụ: nếu bạn ghi một giá trị vào địa chỉ A, rồi 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 hoạt động 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à tính nhất quán tuần tự, được mô tả như sau (Adve và Gharachorloo):

  • Tất cả các thao tác bộ nhớ dường như thực thi từng thao tác một
  • Tất cả thao tác trong một luồng dường như được thực thi theo thứ tự mà chương trình của bộ xử lý đó mô tả.

Tạm thời, hãy giả sử rằng 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 có gì bất ngờ: Trình biên dịch hoặc trình thông dịch này dịch các phép gán trong mã nguồn để tải và lưu trữ các lệnh theo đúng thứ tự tương ứng, mỗi lệnh cho một lần truy cập. Chúng ta cũng giả định rằng mỗi luồng thực thi trên bộ xử lý riêng để đơn giản hoá.

Nếu xem xét một đoạn mã và thấy rằng đoạn mã đó thực hiện một số thao tác đọc và ghi từ bộ nhớ, thì trên một cấu trúc CPU nhất quán tuần tự, bạn sẽ biết rằng đoạn mã đó sẽ thực hiện các thao tác đọ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 rằng CPU đang làm bất cứ điều gì khác ngoài việc thực thi các lệnh một cách đơn giản. (Chúng ta sẽ bỏ qua I/O của 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à kiểm thử quỳ tím.

Dưới đây là một ví dụ đơn giản, với mã chạy trên hai luồng:

Chuỗi 1 Chuỗi 2
A = 3
B = 5
reg0 = B
reg1 = A

Trong ví dụ này và tất cả các ví dụ về litmus trong tương lai, vị trí bộ nhớ được biểu thị bằng các chữ cái viết hoa (A, B, C) và thanh ghi CPU bắt đầu bằng "reg". Ban đầu, tất cả bộ nhớ đều bằng 0. Các lệnh đượ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. Luồng 2 tải giá trị từ vị trí B vào reg0, sau đó tải giá trị từ vị trí A vào reg1. (Lưu ý rằng chúng ta đang ghi theo một thứ tự và đọc theo một thứ tự khác.)

Luồng 1 và luồng 2 được giả định là thực thi trên các lõi CPU khác nhau. Bạn luôn phải đưa ra giả định 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 thực thi xong, các thanh ghi sẽ ở một trong các trạng thái sau:

Đăng ký Tiểu bang
reg0=5, reg1=3 có thể (chuỗi 1 chạy trước)
reg0=0, reg1=0 có thể (luồng 2 chạy trước)
reg0=0, reg1=3 có thể (thực thi đồng thời)
reg0=5, reg1=0 không bao giờ

Để rơi vào tình huống mà chúng ta thấy B=5 trước khi thấy lưu trữ thành A, thì các lượt đọc hoặc lượt ghi sẽ phải xảy ra không theo thứ tự. Trên một máy nhất quán tuần tự, điều đó không thể xảy ra.

Bộ xử lý đơn, bao gồm x86 và ARM, thường nhất quán theo tuần tự. Các luồng có vẻ như thực thi theo kiểu xen kẽ, khi hạt nhân hệ điều hành chuyển đổi giữa các luồng. Hầu hết các hệ thống SMP, bao gồm cả x86 và ARM, đều không nhất quán tuần tự. Ví dụ: phần cứng thường lưu vào vùng đệm các lệnh lưu trữ trên đường đến bộ nhớ, để các lệnh này không đến bộ nhớ ngay lập tức và các lõi khác không nhìn thấy được.

Thông tin chi tiết có thể khác nhau đáng kể. Ví dụ: x86, mặc dù không nhất quán tuần tự, vẫn đảm bảo rằng reg0 = 5 và reg1 = 0 vẫn không thể xảy ra. Các cửa hàng được lưu vào vùng đệm, nhưng thứ tự của chúng vẫn được duy trì. Mặt khác, ARM thì không. Thứ tự của các cửa hàng được lưu vào vùng đệm không được duy trì và các cửa hàng có thể không tiếp cận được tất cả các lõi khác cùng một lúc. Những điểm khác biệt này rất quan trọng đối với lập trình viên hợp ngữ. 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 ẩn đi những khác biệt về cấu trúc như vậy.

Cho đến nay, chúng ta đã giả định một cách không thực tế rằng chỉ có 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 để 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ã sau này trong Luồng 2 cần giá trị của reg1 trước khi cần reg0, và do đó tải reg1 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ó thể sắp xếp lại 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 quá trình thực thi của một luồng, và có thể cải thiện đáng kể hiệu suất. Như chúng ta sẽ thấy, nếu chú ý một chút, chúng ta cũng có thể ngăn chặn việc 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 các quyền truy cập bộ nhớ, nên vấn đề này thực sự không mới đối với SMP. Ngay cả trên một bộ xử lý đơn, trình biên dịch cũng có thể sắp xếp lại các lượt tải thành 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ì có thể chúng ta sẽ không bao giờ phát hiện ra 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 sắp xếp lại, việc sắp xếp lại có thể sẽ được nhìn thấy, có thể sau một số lượng rất lớn các lần thực thi thành công. Trừ phi bạn đang lập trình bằng hợp ngữ, SMP thường chỉ giúp bạn dễ dàng nhận thấy các vấn đề đã tồn tại từ trước.

Lập trình không có tranh chấp dữ liệu

May mắn là thường có một cách dễ dàng để tránh phải suy nghĩ về bất kỳ chi tiết nào trong số này. Nếu bạn tuân theo một số quy tắc đơn giản, thì thường sẽ an toàn khi bỏ qua tất cả phần trước, ngoại trừ phần "tính nhất quán tuần tự". Rất tiếc, các vấn đề khác có thể xuất hiện 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ó tranh chấp dữ liệu". Miễn là bạn hứa không đưa ra "cuộc đ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 hứa sẽ cung cấp kết quả nhất quán tuần tự. Điều này không thực sự có nghĩa là chúng tránh được việc sắp xếp lại thứ tự truy cập bộ nhớ. Điều này có nghĩa là nếu bạn tuân thủ các quy tắc, bạn sẽ không thể biết rằng các quyền truy cập bộ nhớ đang được sắp xếp lại. Nó giống như việc nói với bạn rằng xúc xích là một món ăn ngon và hấp dẫn, miễn là bạn hứa không ghé thăm nhà máy xúc xích. Tình trạng tranh chấp dữ liệu là điều cho thấy sự thật không mấy dễ chịu về việc sắp xếp lại bộ nhớ.

"Cuộc đua dữ liệu" là gì?

Cuộc đua dữ liệu xảy ra khi có ít nhất hai luồng đồng thời truy cập 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 đó. "Dữ liệu thông thường" là dữ liệu không phải là đối tượng đồng bộ hoá dành riêng cho hoạt động giao tiếp giữa các luồng. Mutex, biến điều kiện, biến volatile trong Java hoặc đối tượng nguyên tử trong C++ không phải là dữ liệu thông thường và các quyền truy cập vào các đối tượng này được phép tranh chấp. Trên thực tế, chúng được dùng để ngăn chặn tình trạng tranh chấp dữ liệu trên các đối tượng khác.

Để 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 nội dung 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 đây không có xung đột dữ liệu nếu A và B là các biến boolean thông thường có giá trị ban đầu là false:

Chuỗi 1 Chuỗi 2
if (A) B = true if (B) A = true

Vì các thao tác không được sắp xếp lại, nên cả hai điều kiện sẽ được đánh giá là false và không có biến nào được cập nhật. Do đó, không thể xảy ra xung đột dữ liệu. Bạn không cần phải nghĩ về những gì có thể xảy ra nếu thao tác 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 Luồng 1 bằng cách viết lại thành "B = true; if (!A) B = false". Điều đó giống như việc làm xúc xích giữa thị trấn vào ban ngày.

Tình trạng tranh đấu dữ liệu được định nghĩa chính thức trên các kiểu dữ liệu tích hợp cơ bản như số nguyên và tham chiếu hoặc con trỏ. Việc gán cho một int trong khi đồng thời đọc nó trong một luồng khác rõ ràng là một cuộc đua dữ liệu. Tuy nhiên, cả thư viện chuẩn C++ và thư viện Java Collections đều được viết để cho phép bạn suy luận về các cuộc tranh đấu dữ liệu ở cấp thư viện. Các phương thức này hứa hẹn sẽ không gây ra xung đột dữ liệu, trừ phi có các quyền truy cập đồng thời vào cùng một vùng chứa, trong đó có ít nhất một quyền truy cập cập nhật vùng chứa đó. Việc cập nhật set<T> trong một luồng trong khi đồng thời đọc luồng đó trong một luồng khác cho phép thư viện đưa ra một cuộc đua dữ liệu, và do đó có thể được coi là "cuộc đua dữ liệu cấp thư viện". Ngược lại, việc cập nhật một set<T> trong một luồng, trong khi đọc một luồng khác trong một luồng khác, không dẫn đến tranh đua dữ liệu, vì thư viện hứa hẹn sẽ không đưa ra tranh đua dữ liệu (cấp thấp) trong trường hợp đó.

Thông thường, cá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ể gây ra xung đột 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ớ". Việc truy cập vào bất kỳ trường bit nào trong một chuỗi như vậy được coi là truy cập vào tất cả các trường bit đó nhằm mục đích xác định sự tồn tại của một cuộc tranh chấp dữ liệu. Điều này phản ánh việc phần cứng thông thường không thể cập nhật từng bit riêng lẻ mà không đọc và ghi lại các bit liền kề. Lập trình viên Java không có mối lo ngại tương tự.

Tránh tranh chấp dữ liệu

Các ngôn ngữ lập trình hiện đại cung cấp một số cơ chế đồng bộ hoá để tránh xung đột dữ liệu. Các công cụ cơ bản nhất là:

Khoá hoặc Mutex
Bạn có thể sử dụng Mutex (std::mutex hoặc pthread_mutex_t trong C++11) hoặc các khối synchronized 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ẽ gọi chung các cơ sở này và các cơ sở tương tự khác là "khoá". Việc liên tục lấy một khoá cụ thể trước khi truy cập vào cấu trúc dữ liệu dùng chung và giải phóng khoá đó sau đó sẽ ngăn chặn tình trạng tranh chấp dữ liệu khi truy cập vào cấu trúc dữ liệu. Điều 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 để ngăn chặn tình trạng tranh đấu dữ liệu. Việc sử dụng các khối synchronized của Java hoặc lock_guard hoặc unique_lock của C++ đảm bảo rằng các khoá được giải phóng đúng cách trong trường hợp có 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 xung đột dữ liệu. Kể từ năm 2011, C và C++ hỗ trợ các biến và trường atomic có ngữ nghĩa tương tự. Các biến này thường khó sử dụng hơn khoá vì chúng chỉ đảm bảo rằng các quyền truy cập riêng lẻ vào một biến duy nhất là nguyên tử. (Trong C++, điều này thường mở rộng đến các thao tác đọc-sửa đổi-ghi đơn giản, chẳng hạn như tăng dần. Java yêu cầu các lệnh gọi phương thức đặc biệt cho việc đó.) Không giống như khoá, bạn không thể sử dụng trực tiếp các biến volatile hoặc atomic để ngăn các luồng khác can thiệp vào các chuỗi mã dài hơn.

Xin lưu ý rằng volatile có ý nghĩa rất khác nhau trong C++ và Java. Trong C++, volatile không ngăn chặn được tình trạng tranh chấp dữ liệu, mặc dù mã cũ thường sử dụng từ khoá này để giải quyết vấn đề thiếu đối tượng atomic. Bạn không nên sử dụng từ khoá này nữa; trong C++, hãy sử dụng atomic<T> cho các biến có thể được nhiều luồng truy cập đồng thời. volatile C++ dành cho các thanh ghi thiết bị và những thành phần tương tự.

Bạn có thể dùng các biến atomic C/C++ hoặc biến volatile Java để ngăn chặn tình trạng tranh đấu dữ liệu trên các biến khác. Nếu flag được khai báo là 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 không có xung đột dữ liệu:

Chuỗi 1 Chuỗi 2
A = ...
  flag = true
while (!flag) {}
... = A

Vì Luồng 2 chờ flag được đặt, 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 việc gán cho A trong Luồng 1. Do đó, không có xung đột dữ liệu trên A. Tình trạng tương tranh trên flag không được tính là tương tranh dữ liệu, vì các quyền truy cập không ổn định/nguyên tử không phải là "quyền truy cập bộ nhớ thông thường".

Quá trình triển khai cần phải ngăn chặn hoặc ẩn việc sắp xếp lại bộ nhớ đủ để khiến mã như kiểm thử litmus 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ớ không ổn định/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ó tranh chấp dữ liệu, nhưng khoá cùng với Object.wait() trong Java hoặc biến điều kiện trong C/C++ thường cung cấp giải pháp tốt hơn, không liên quan đến việc chờ đợi trong vòng lặp trong khi tiêu hao pin.

Thời điểm tính năng sắp xếp lại kỷ niệm xuất hiện

Lập trình không có tranh chấp dữ liệu thường giúp chúng ta không phải xử lý rõ ràng các vấn đề về việc sắp xếp lại thứ tự 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ị:
  1. Nếu chương trình của bạn có lỗi dẫn đến tình trạng tranh chấp dữ liệu ngoài ý muốn, thì các phép biến đổi của trình biên dịch và phần cứng có thể xuất hiện và hành vi của chương trình có thể gây bất ngờ. Ví dụ: nếu chúng ta quên khai báo flag volatile trong ví dụ trước, thì Luồng 2 có thể thấy một A chưa được khởi chạy. Hoặc trình biên dịch có thể quyết định rằng cờ không thể thay đổi trong vòng lặp của Luồng 2 và chuyển đổi chương trình thành
    Chuỗi 1 Chuỗi 2
    A = ...
      flag = true
    reg0 = flag; while (!reg0) {}
    ... = A
    Khi gỡ lỗi, bạn có thể thấy vòng lặp tiếp tục mãi mãi mặc dù flag là đúng.
  2. C++ cung cấp các phương tiện để nới lỏng tính nhất quán tuần tự một cách rõ ràng ngay cả khi không có xung đột. Các thao tác nguyên tử có thể nhận các đối số memory_order_... rõ ràng. Tương tự, gói java.util.concurrent.atomic cung cấp một nhóm các tiện ích tương tự nhưng hạn chế hơn, đặc biệt là lazySet(). Và các lập trình viên Java đôi khi sử dụng các cuộc đua dữ liệu có chủ ý để có hiệu ứng tương tự. Tất cả những điều này đều giúp cải thiện hiệu suất nhưng lại làm tăng đáng kể độ phức tạp của việc lập trình. Chúng tôi chỉ thảo luận ngắn gọn về các phương pháp này ở bên dưới.
  3. Một số mã C và C++ được viết theo kiểu cũ, không hoàn toàn nhất quán với các tiêu chuẩn ngôn ngữ hiện tại, trong đó các biến volatile được dùng thay vì các biến atomic và thứ tự bộ nhớ bị nghiêm cấm bằng cách chèn cái gọi là hàng rào hoặc rào cản. Điều này đòi hỏi phải có lý luận rõ ràng về việc sắp xếp lại quyền truy cập và hiểu biết về các mô hình bộ nhớ phần cứng. Kiểu lập trình theo hướng này vẫn được dùng trong nhân Linux. Bạn không nên sử dụng lớp này trong các ứng dụng Android mới và lớp này cũng không được thảo luận thêm tại đâ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 việc thiếu khai báo khoá, atomic hoặc volatile khiến một số mã đọc dữ liệu cũ, thì bạn có thể không tìm ra lý do bằng cách kiểm tra tệp báo lỗi bộ nhớ bằng trình gỡ lỗi. Khi bạn có thể đưa ra truy vấn trình gỡ lỗi, các lõi CPU có thể đã quan sát thấy toàn bộ tập hợp quyền truy cập, đồng thời nội dung của bộ nhớ và thanh ghi CPU sẽ xuất hiện ở trạng thái "không thể".

Những việc không nên làm trong C

Sau đây là 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. Trước khi làm việc đó, chúng ta cần thảo luận về việc sử dụng một tính năng ngôn ngữ cơ bản.

C/C++ và "volatile"

Khai báo volatile trong C và C++ là một công cụ có mục đích rất đặc biệt. Các rào cản này ngăn trình biên dịch sắp xếp lại hoặc xoá các quyền truy cập volatile. Điều này có thể hữu ích cho mã truy cập vào các thanh ghi thiết bị phần cứng, bộ nhớ được liên kết với nhiều vị trí hoặc liên quan đến setjmp. Nhưng C và C++ volatile, không giống như Java volatile, không được thiết kế để giao tiếp theo luồng.

Trong C và C++, các quyền truy cập vào dữ liệu volatile có thể được sắp xếp lại bằng quyền truy cập vào dữ liệu không biến động và không có đảm bảo về tính nguyên tử. Do đó, bạn không thể dùng volatile để chia sẻ dữ liệu giữa các luồng trong mã di động, ngay cả trên một bộ xử lý đơn. C volatile thường không ngăn việc sắp xếp lại quyền truy cập bằng phần cứng, vì vậy, bản thân nó thậm chí còn ít hữu ích hơn trong môi trường SMP đa luồng. Đây là lý do C11 và C++11 hỗ trợ các đối tượng atomic. Bạn nên sử dụng các phương thức đó.

Nhiều mã C và C++ cũ vẫn lạm dụng 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 thanh ghi máy, miễn là dữ liệu đó được sử dụng với hàng rào rõ ràng hoặc trong trường hợp thứ tự bộ nhớ không quan trọng. Tuy nhiên, không có gì đảm bảo rằng cách này sẽ hoạt động 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 sử dụng khoá (như pthread_mutex_t hoặc C++11 std::mutex) thay vì thao tác nguyên tử, nhưng chúng tôi sẽ sử dụng thao tác nguyên tử để minh hoạ cách sử dụng thao tác này trong một 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 ta "xuất bản" cấu trúc đó bằng cách lưu trữ trong một biến toàn cục. Tại 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ì luồng này đã được khởi chạy hoàn toàn, đúng không?

Vấn đề là có thể quan sát thấy việc lưu trữ vào gGlobalThing trước khi các trường được khởi tạo, thường là do trình biên dịch hoặc bộ xử lý sắp xếp lại việc lưu trữ vào gGlobalThing và thing->x. Một luồng khác đọc từ thing->x có thể thấy 5, 0 hoặc thậm chí là dữ liệu chưa được khởi tạo.

Vấn đề cốt lõi ở đây là tranh chấp dữ liệu trên gGlobalThing. Nếu Luồng 1 gọi initGlobalThing() trong khi Luồng 2 gọi useGlobalThing(), thì gGlobalThing có thể được đọc trong khi đang được ghi.

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 rằng các lượt ghi sẽ hiển thị cho các luồng khác theo đúng thứ tự. Ngoài ra, chế độ này cũng đảm bảo ngăn chặn một số chế độ lỗi khác được cho phép, nhưng không có khả năng xảy ra trên phần cứng Android thực. Ví dụ: điều này đảm bảo rằng chúng ta không thể thấy một con trỏ gGlobalThing chỉ được ghi một phần.

Những điều không nên làm trong Java

Chúng ta chưa thảo luận về một số tính năng ngôn ngữ Java có liên quan, vì vậy, trước tiên, chúng ta sẽ xem nhanh các tính năng đó.

Về mặt kỹ thuật, Java không yêu cầu mã phải không có xung đột dữ liệu. Và có một lượng nhỏ mã Java được viết rất cẩn thận, hoạt động chính xác khi có xung đột dữ liệu. Tuy nhiên, việc viết mã như vậy là cực kỳ phức tạp và chúng tôi chỉ thảo luận ngắn gọn về vấn đề này dưới đây. Tệ hơn nữa, các chuyên gia chỉ định ý nghĩa của mã đó không còn tin rằng thông số kỹ thuật là chính xác. (Thông số kỹ thuật này phù hợp với mã không có tranh chấp dữ liệu.)

Hiện tại, chúng ta sẽ tuân thủ mô hình không có tranh chấp dữ liệu, trong đó Java cung cấp các đảm bảo về cơ bản giống như C và C++. Xin nhắc lại, ngôn ngữ này cung cấp một số dữ liệu gốc giúp giảm bớt tính nhất quán tuần tự, đặc biệt là các lệnh gọi lazySet() và weakCompareAndSet() trong java.util.concurrent.atomic. Giống như với C và C++, chúng ta sẽ bỏ qua các tệp này vào lúc này.

Từ khoá "synchronized" và "volatile" của Java

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ể 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ẽ đợi cho đến khi luồng kia hoàn tất.

Như đã đề cập ở trên, volatile T của Java tương tự như atomic<T> của C++11. Bạn được phép truy cập đồng thời vào các trường volatile và việc này không dẫn đến tình trạng tranh đấu dữ liệu. Bỏ qua lazySet() và các cuộc đua dữ liệu, nhiệm vụ của Máy ảo Java là đảm bảo kết quả vẫn nhất quán theo tuần tự.

Cụ thể, nếu luồng 1 ghi vào một trường volatile và sau đó luồng 2 đọc từ chính trường đó và thấy giá trị mới được ghi, thì luồng 2 cũng được đảm bảo sẽ thấy tất cả các lượt ghi trước đó của luồng 1. Về hiệu ứng bộ nhớ, việc ghi vào một biến volatile tương tự như việc giải phóng một monitor, và việc đọc từ một biến volatile giống như việc lấy một monitor.

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 lưu trữ nguyên tử. Không giống như C++, toàn bộ phép tăng không phải là nguyên tử. Thay vào đó, các thao tác tăng dần nguyên tử được cung cấp bởi java.util.concurrent.atomic.

Ví dụ

Dưới đây là cách triển khai đơn giản, không chính xác của bộ đếm đơn điệu: (Lý thuyết và thực hành về Java: Quản lý tính 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 sự là ba thao tác:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

Nếu hai luồng thực thi đồng thời trong incr(), thì một trong các bản cập nhật có thể bị mất. Để thực hiện phép tăng dần nguyên tử, chúng ta cần khai báo incr() "synchronized".

Tuy nhiên, tính năng này vẫn bị lỗi, đặc biệt là trên SMP. Vẫn còn một cuộc đua dữ liệu, trong đó get() có thể truy cập đồng thời vào mValue bằng incr(). Theo quy tắc của Java, lệnh gọi get() có thể xuất hiện để sắp xếp lại thứ tự đối với mã khác. Ví dụ: nếu chúng ta đọc hai bộ đếm liên tiếp, kết quả có thể 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 đã đưa ra 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() cần được đồng bộ hoá, chúng ta có thể khai báo mValue bằng "volatile". (Lưu ý rằng incr() vẫn phải sử dụng synchronize vì nếu không, mValue++ sẽ không phải là một thao tác nguyên tử đơn lẻ.) Điều này cũng giúp tránh mọi tình trạng tranh đấu dữ liệu, nhờ đó duy trì tính nhất quán tuần tự. incr() sẽ chậm hơn một chút vì phải chịu cả chi phí vào/ra của trình giám sát và chi phí liên quan đến kho lưu trữ không ổn định, nhưng get() sẽ nhanh hơn, vì vậy, ngay cả khi không có tranh chấp, đây vẫn là một lợi thế nếu số lượt đọc vượt trội hơn nhiều so với số lượt ghi. (Xem thêm AtomicInteger để biết cách xoá hoàn toàn khối đồng bộ hoá.)

Sau đây là một ví dụ khác, có dạng tương tự như các ví dụ về 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
            ....
        }
    }
}

Vấn đề này cũng giống như mã C, cụ thể là có một cuộc đua dữ liệu trên sGoodies. Do đó, việc gán sGoodies = goods có thể được quan sát trước khi khởi chạy các trường trong goods. Nếu bạn khai báo sGoodies bằng từ khoá volatile, thì tính nhất quán tuần tự sẽ được khôi phục và mọi thứ sẽ hoạt động như mong đợi.

Xin lưu ý rằng chỉ có tệp đối chiếu sGoodies là không ổn định. Các quyền truy cập vào các trường bên trong thì không. Sau khi sGoodies là volatile và thứ tự bộ nhớ được giữ nguyên, các trường không thể được truy cập đồng thời. Câu lệnh z = sGoodies.x sẽ thực hiện tải không ổn định MyClass.sGoodies, sau đó là tải ổn định sGoodies.x. Nếu bạn tạo một tham chiếu cục bộ MyGoodies localGoods = sGoodies, thì z = localGoods.x tiếp theo sẽ không thực hiện bất kỳ lượt tải không ổn định nào.

Một thành ngữ phổ biến hơn trong lập trình Java là "khoá được kiểm tra kỹ" (double-checked locking):

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 đối tượng Helper liên kết với một thực thể của MyClass. Chúng ta chỉ được tạo một lần, vì vậy, chúng ta sẽ tạo và trả về thông qua một hàm getHelper() chuyên dụng. Để tránh tình trạng tranh chấp khi hai luồng tạo thực thể, chúng ta cần đồng bộ hoá việc tạo đối tượng. Tuy nhiên, chúng ta không muốn tốn tài nguyên cho khối "synchronized" (đã đồng bộ hoá) trong 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.

Điều này có một cuộc đua dữ liệu trên trường helper. Bạn có thể đặt thuộc tính này đồng thời với helper == null trong một luồng khác.

Để xem cách thức lỗi này xảy ra, hãy xem xét cùng một mã được viết lại một chút, như thể mã đó được biên dịch thành một ngôn ngữ giống như 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ì ngăn cản phần cứng hoặc trình biên dịch sắp xếp lại kho lưu trữ thành helper với các trường x/y. Một luồng khác có thể tìm thấy helper không rỗng nhưng các trường của luồng đó chưa được đặt và chưa 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 đường liên kết "Tuyên bố "Double Checked Locking is Broken" (Khoá kiểm tra kép bị hỏng)" trong phần phụ lục để biết thêm thông tin chi tiết hoặc Mục 71 ("Sử dụng tính năng khởi chạy từng phần một cách hợp lý") trong cuốn sách Effective Java, 2nd Edition (Java hiệu quả, Phiên bản thứ 2) của Josh Bloch.

Có hai cách để khắc phục vấn đề này:

  1. Hãy làm theo cách đơn giản và xoá quy trình kiểm tra bên ngoài. Điều này đảm bảo rằng chúng ta không bao giờ kiểm tra giá trị của helper bên ngoài một khối được đồng bộ hoá.
  2. Khai báo helper volatile. Chỉ 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 có thể dành một phút để tự thuyết phục bản thân rằng điều này là đúng.)

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 bản cập nhật cho vol1, thì luồng này không thể biết liệu data1 hay data2 đã được đặt hay chưa. Sau khi thấy bản cập nhật cho vol1, nó sẽ biết rằng data1 có thể được truy cập một cách an toàn và đọc chính xác mà không gây ra tranh chấp dữ liệu. Tuy nhiên, trình biên dịch không thể đưa ra bất kỳ giả định nào về data2, vì thao tác lưu trữ đó được thực hiện sau thao tác lưu trữ không ổn định.

Xin lưu ý rằng bạn không thể dùng volatile để ngăn việc sắp xếp lại các quyền truy cập bộ nhớ khác đang cạnh tranh với nhau. Không đảm bảo tạo ra lệnh rào chắn bộ nhớ máy. Bạn có thể dùng hàm này để ngăn chặn tình trạng tương tranh dữ liệu bằng cách chỉ thực thi mã khi một luồng khác đã đáp ứng một điều kiện nhất định.

Việc nên làm

Trong C/C++, hãy ưu tiên các lớp đồng bộ hoá C++11, chẳng hạn như std::mutex. Nếu không, hãy sử dụng các thao tác pthread tương ứng. Các lớp này bao gồm các rào cản bộ nhớ thích hợp, cung cấp hành vi chính xác (nhất quán tuần tự trừ phi có quy định khác) và hiệu quả trên tất cả các phiên bản nền tảng Android. Hãy nhớ sử dụng các thẻ này đúng cách. Ví dụ: hãy nhớ rằng các lệnh chờ biến điều kiện có thể trả về kết quả giả mà không được báo hiệu, do đó sẽ xuất hiện trong một vòng lặp.

Tốt nhất là bạn 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, chẳng hạn như bộ đếm. Việc khoá và mở khoá một mutex pthread yêu cầu một 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 các thao tác nguyên tử. Thiết kế không khoá cho các cấu trúc dữ liệu không tầm thường đòi hỏi phải chú ý nhiều hơn để đảm bảo rằng các thao tác cấp cao hơn trên cấu trúc dữ liệu xuất hiện dưới dạng nguyên tử (như một tổng thể, không chỉ là các phần nguyên tử rõ ràng 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 sâu hơn những gì chúng tôi đã trình bày cho đến nay. Một phần lớn mã hiện có sử dụng các mã này được phát hiện là có lỗi sau đó. Tránh những điều 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 trong phần tiếp theo, hãy đảm bảo bạn là chuyên gia hoặc đã tham khảo ý kiến của chuyên gia.

Tránh sử dụng volatile để giao tiếp giữa các luồng trong C/C++.

Trong Java, các vấn đề về tính đồng thời thường được giải quyết tốt nhất bằng cách sử dụng một lớp tiện ích thích hợp trong gói java.util.concurrent. Mã được viết tốt và được kiểm thử kỹ lưỡng trên SMP.

Có lẽ điều an toàn nhất bạn có thể làm là đặt các đối tượng của mình ở trạng thái không thể thay đổi. Các đối tượng từ các lớp như String và Integer của Java lưu giữ dữ liệu không thể thay đổi sau khi đối tượng được tạo, tránh mọi khả năng xảy ra tranh chấp dữ liệu trên các đối tượng đó. Cuốn sách Effective Java, 2nd Ed. (Java hiệu quả, phiê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". Hãy đặc biệt lưu ý tầm quan trọng của việc khai báo các trường Java là "final" (Bloch).

Ngay cả khi một đối tượng không thể thay đổi, hãy nhớ rằng việc truyền đối tượng đó đến một luồng khác mà không có bất kỳ kiểu đồng bộ hoá nào là một cuộc tranh chấp dữ liệu. Đôi khi, điều này có thể chấp nhận được trong Java (xem bên dưới), nhưng cần hết sức cẩn thận và có thể dẫn đến mã dễ hỏng. Nếu không quá quan trọng về hiệu suất, hãy thêm nội dung khai báo volatile. Trong C++, việc truyền một con trỏ hoặc tham chiếu đến một đối tượng không thể thay đổi mà không có sự đồng bộ hoá thích hợp, giống như bất kỳ cuộc đua dữ liệu nào, là một lỗi. Trong trường hợp này, có thể xảy ra sự cố không liên tục vì, ví dụ: luồng nhận có thể thấy con trỏ bảng phương thức chưa được khởi tạo do việc sắp xếp lại cửa hàng.

Nếu cả lớp thư viện hiện có và lớp bất biến đều không phù hợp, thì bạn nên sử dụng câu lệnh synchronized của Java hoặc lock_guard / unique_lock của C++ để 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 trường hợp 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 hết sức lưu ý để hiểu rõ các hoạt động 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 được các lỗi lập trình đồng thời phổ biến, nhưng sẽ giúp bạn tránh được các lỗi không rõ nguyên nhân liên quan đến việc tối ưu hoá trình biên dịch và sự cố SMP.

Bạn nên tránh "xuất bản" một tệp đối chiếu đến một đối tượng, tức là cung cấp tệp đối chiếu đó cho các luồng khác, trong hàm khởi tạo của đối tượng đó. Điều này ít quan trọng hơn trong C++ hoặc nếu bạn tuân thủ lời khuyên "không có xung đột dữ liệu" của chúng tôi trong Java. Tuy nhiên, đây luôn là lời khuyên hữu ích và trở nên quan trọng nếu mã Java của bạn chạy trong các bối cảnh khác mà mô hình bảo mật Java quan trọng, đồng thời mã không đáng tin cậy có thể gây ra tranh chấp dữ liệu bằng cách truy cập vào tệp tham chiếu đối tượng "bị rò rỉ" đó. Điều này 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 thông tin chi tiết

Thêm một chút về thứ tự bộ nhớ yếu

C++11 trở lên cung cấp các cơ chế rõ ràng để nới lỏng các đảm bảo về tính nhất quán tuần tự cho các chương trình không có xung đột dữ liệu. Các đối số memory_order_relaxed, memory_order_acquire (chỉ tải) và memory_order_release (chỉ lưu trữ) rõ ràng cho các thao tác nguyên tử đều cung cấp các đảm bảo yếu hơn so với memory_order_seq_cst mặc định, thường là ngầm định. memory_order_acq_rel cung cấp cả đảm bảo memory_order_acquire và memory_order_release cho các thao tác ghi đọc-sửa đổi nguyên tử. memory_order_consume chưa được chỉ định hoặc triển khai đầy đủ để có thể sử dụng, vì vậy, bạn nên bỏ qua thuộc tính này.

Các phương thức lazySet trong Java.util.concurrent.atomic tương tự như các kho lưu trữ memory_order_release của C++. 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 thậm chí còn yếu hơn. Không giống như C++, không có cơ chế thực sự nào cho các quyền truy cập không theo thứ tự vào các biến được khai báo là volatile.

Nhìn chung, bạn nên tránh sử dụng các phương thức này, trừ phi có lý do cấp bách về hiệu suất cần phải sử dụng. Trên các cấu trúc máy được sắp xếp yếu như ARM, việc sử dụng các cấu trúc này thường sẽ tiết kiệm được khoảng vài chục chu kỳ máy cho mỗi thao tác nguyên tử. Trên x86, hiệu suất chỉ tăng lên đối với các cửa hàng và có thể ít nhận thấy hơn. Có một điều hơi trái với lẽ thường là 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 sắp xếp yếu rất phức tạp. Nói chung, các quy tắc này yêu cầu bạn phải hiểu chính xác các quy tắc ngôn ngữ mà chúng ta sẽ không đi sâu vào đây. Ví dụ:

  • Trình biên dịch hoặc phần cứng có thể di chuyển các quyền truy cập memory_order_relaxed vào (nhưng không ra khỏi) một phần quan trọng được giới hạn bằng việc thu nạp và giải phóng khoá. Điều này có nghĩa là hai thao tác lưu trữ memory_order_relaxed có thể xuất hiện không theo thứ tự, ngay cả khi chúng được phân tách bằng một phần quan trọng.
  • Một biến Java thông thường, khi bị lạm dụng làm bộ đếm dùng chung, có thể xuất hiện vào một luồng khác để giảm, mặc dù nó chỉ được tăng thêm một luồng khác. Nhưng điều này không đúng với nguyên tử C++ memory_order_relaxed.

Với cảnh báo đó, sau đây chúng tôi sẽ đưa ra một số thành ngữ dường như bao gồm nhiều trường hợp sử dụng cho các nguyên tử được sắp xếp yếu. Nhiều tính năng trong số này chỉ áp dụng cho C++.

Quyền truy cập không liên quan đến đua xe

Một biến thường là nguyên tử vì đôi khi biến này được đọc đồng thời với một thao tác ghi, nhưng không phải tất cả các quyền truy cập đều gặp phải vấn đề này. Ví dụ: một biến có thể cần phải là nguyên tử vì biến đó được đọc bên ngoài một phần quan trọng, nhưng tất cả nội dung cập nhật đều được bảo vệ bằng khoá. Trong trường hợp đó, một lượt đọc được bảo vệ bằng cùng một khoá sẽ không thể xảy ra xung đột, vì không thể có các lượt ghi đồng thời. Trong trường hợp này, quyền truy cập không tranh chấp (tải trong trường hợp này) có thể được chú thích bằng memory_order_relaxed mà không làm thay đổi tính chính xác của mã C++. Việc 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 luồng khác và memory_order_relaxed chỉ định rằng về cơ bản, không cần thực thi thêm quy tắc ràng buộc nào về thứ tự đối với quyền truy cập nguyên tử.

Không có cấu trúc tương tự trong Java.

Kết quả không được đảm bảo về độ chính xác

Khi chúng ta chỉ sử dụng một tải tranh chấp để tạo gợi ý, thì việc không thực thi bất kỳ thứ tự bộ nhớ nào cho tải đó cũng thường được chấp nhận. Nếu giá trị không đáng tin cậy, chúng ta cũng không thể sử dụng kết quả một cách đáng tin cậy để suy luận bất cứ điều gì về các biến khác. Do đó, việc thứ tự bộ nhớ không được đảm bảo và tải được cung cấp đối số memory_order_relaxed là điều bình thường.

Một trường hợp phổ biến là việc sử dụng compare_exchange của C++ để thay thế x bằng f(x) một cách tự động. Lần 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 đối số memory_order_relaxed cho lần tải ban đầu của x; chỉ có thứ tự bộ nhớ cho compare_exchange thực tế mới quan trọng.

Dữ liệu được sửa đổi tự động nhưng chưa đọc

Đôi khi, dữ liệu được nhiều luồng sửa đổi song song, nhưng không được kiểm tra cho đến khi quá trình tính toán song song hoàn tất. Một ví dụ điển hình cho trường hợp này là một bộ đếm được tăng dần theo 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ị kết quả chỉ được đọc ở cuối, sau khi tất cả các bản 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ác quyền truy cập vào dữ liệu này có được sắp xếp lại hay không, và do đó mã C++ có thể sử dụng đối số memory_order_relaxed.

Bộ đếm sự kiện đơn giản là một ví dụ phổ biến về điều này. Vì đây là trường hợp rất phổ biến, nên chúng ta cần đưa ra một số nhận xét về trường hợp này:

  • Việc sử dụng memory_order_relaxed giúp cải thiện hiệu suất, nhưng có thể không giải quyết được vấn đề về hiệu suất quan trọng nhất: Mỗi lần cập nhật đều 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ác bản cập nhật diễn ra thường xuyên và xen kẽ giữa các luồng, thì việc tránh cập nhật bộ đếm dùng chung mỗi lần sẽ nhanh hơn nhiều, ví dụ: bằng cách sử dụng bộ đếm cục bộ của luồng và tính tổng các bộ đếm đó ở cuối.
  • Bạn có thể kết hợp kỹ thuật này với phần trước: Có thể đồng thời đọc các giá trị gần đúng và không đáng tin cậy trong khi các giá trị đó đang được cập nhật, trong đó tất cả thao tác đều sử dụng memory_order_relaxed. Tuy nhiên, điều quan trọng là phải coi các giá trị thu được là hoàn toàn không đáng tin cậy. Chỉ vì số lượng dường như đã được tăng lên một lần không có nghĩa là một luồng khác có thể được tính là đã đạt đến điểm mà tại đó việc tăng đã được thực hiện. Thay vào đó, phép tăng có thể đã được sắp xếp lại bằng mã trước đó. (Đối với trường hợp tương tự mà chúng tôi đã đề cập trước đó, C++ đảm bảo rằng lần tải thứ hai của một bộ đếm như vậy sẽ không trả về giá trị nhỏ hơn lần tải trước đó trong cùng một luồng. Trừ phi bộ đếm bị tràn.)
  • Thông thường, bạn sẽ thấy mã cố gắng tính toán các giá trị bộ đếm gần đúng bằng cách thực hiện các thao tác đọc và ghi riêng lẻ (hoặc không), nhưng không thực hiện việc tăng dần dưới dạng một nguyên tử tổng thể. Lý do thường được đưa ra là điều này "đủ gần" với bộ đếm hiệu suất hoặc những bộ đếm tương tự. Thường là không. Khi các bản cập nhật đủ thường xuyên (một trường hợp bạn có thể quan tâm), một phần lớn số lượng thường bị mất. Trên thiết bị lõi tứ, thông thường hơn một nửa số lượng có thể bị mất. (Bài tập dễ: xây dựng một kịch bản 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.)

Giao tiếp đơn giản bằng cờ

Thao tác lưu trữ memory_order_release (hoặc thao tác đọc-sửa đổi-ghi) đảm bảo rằng nếu sau đó thao tác tải memory_order_acquire (hoặc thao tác đọc-sửa đổi-ghi) đọc giá trị đã ghi, thì thao tác đó cũng sẽ quan sát mọi thao tác lưu trữ (thông thường hoặc nguyên tử) trước thao tác lưu trữ memory_order_release. Ngược lại, mọi lượt tải trước memory_order_release sẽ không quan sát thấy bất kỳ lượt lưu trữ nào sau lượt tải memory_order_acquire. Không giống như memory_order_relaxed, điều này cho phép các thao tác nguyên tử như vậy được dùng để truyền đạt tiến trình của một luồng đến một luồng khác.

Ví dụ: chúng ta có thể viết lại ví dụ về khoá được kiểm tra kỹ lưỡng ở trên bằng 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;
    }
};

Lệnh tải thu nạp và lưu trữ phát hành đảm bảo rằng nếu chúng ta thấy một helper không rỗng, thì chúng ta cũng sẽ thấy các trường của nó được khởi tạo chính xác. Chúng tôi cũng đã kết hợp quan sát trước đó rằng các tải không đua có thể sử dụng memory_order_relaxed.

Một lập trình viên Java có thể biểu thị helper dưới dạng java.util.concurrent.atomic.AtomicReference<Helper> và sử dụng lazySet() làm kho lưu trữ bản phát hành. Các thao tác tải sẽ tiếp tục sử dụng lệnh gọi get() đơn giản.

Trong cả hai trường hợp, việc điều chỉnh hiệu suất của chúng tôi đều tập trung vào đường dẫn khởi chạy, điều này khó có thể ảnh hưởng đến hiệu suất. Một cách điều chỉnh dễ đọc hơn có thể là:

    Helper* getHelper() {
      Helper* myHelper = helper.load(memory_order_acquire);
      if (myHelper != nullptr) {
        return myHelper;
      }
      lock_guard&ltmutex> lg(mtx);
      if (helper == nullptr) {
        helper = new Helper();
      }
      return helper;
    }

Điều này cung cấp cùng một đường dẫn nhanh, nhưng sử dụng các thao tác mặc định, nhất quán tuần tự trên đường dẫn chậm không quan trọng về hiệu suất.

Ngay cả ở đây, helper.load(memory_order_acquire) có khả năng tạo cùng một mã trên các cấu trúc hiện được Android hỗ trợ dưới dạng tham chiếu thuần tuý (nhất quán tuần tự) đến helper. Thực sự thì 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 làm việc đó.

Thứ tự thu nạp/giải phóng không ngăn các cửa hàng bị trì hoãn rõ ràng và không đảm bảo rằng các cửa hàng sẽ hiển thị với các luồng khác theo thứ tự nhất quán. Do đó, nó không hỗ trợ một mẫu mã hoá 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 điều gì đó; nếu một luồng t nhận thấy rằng không có luồng nào khác đang cố gắng làm điều 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. Không có luồng nào khác có thể tiếp tục, vì cờ của t vẫn được đặt. Điều này sẽ không thành công nếu cờ được truy cập bằng thứ tự thu nạp/giải phóng, vì điều đó không ngăn việc hiển thị cờ của một luồng cho các luồng khác muộn, sau khi các luồng đó đã tiến hành sai. memory_order_seq_cst mặc định có ngăn chặn việc này.

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, thì bạn có thể khởi tạo và sau đó đọc trường đó bằng cách sử dụng các quyền truy cập được sắp xếp yếu. Trong C++, bạn có thể khai báo biến này là atomic và truy cập bằng memory_order_relaxed. Trong Java, bạn có thể khai báo biến này mà không cần volatile và truy cập mà không cần biện pháp đặc biệt. Điều này đòi hỏi tất cả những điều sau đây:

  • Bạn có thể biết được trường này đã được khởi tạo hay chưa dựa vào giá trị của chính trường đó. Để truy cập vào trường, giá trị kiểm tra và trả về của đường dẫn nhanh chỉ nên đọc trường một lần. Trong Java, điều sau là cần thiết. Ngay cả khi trường kiểm thử được 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++, quy tắc "đọc một lần" chỉ là một phương pháp hay.
  • Cả quá trình khởi chạy và các lần tải tiếp theo đều phải là nguyên tử, trong đó không được hiển thị các bản cập nhật một phần. Đối với Java, trường này không được là long hoặc double. Đối với C++, bạn cần có một phép gán nguyên tử; việc tạo phép gán tại chỗ sẽ không hoạt động, vì việc tạo atomic không phải là nguyên tử.
  • Các lần khởi chạy lặp lại phải an toàn, vì nhiều luồng có thể đọc giá trị chưa khởi chạy cùng một lúc. Trong C++, điều này thường tuân theo yêu cầu "có thể sao chép đơ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 phân bổ lại trong hàm khởi tạo sao chép và sẽ không thể sao chép đơn giản. Đối với Java, một số loại tham chiếu nhất định được chấp nhận:
  • Các tệp tham chiếu Java chỉ được dùng cho các loại không thể thay đổi và chỉ chứa các trường cuối cùng. Hàm khởi tạo của loại không thể thay đổi không được xuất bản thông tin tham chiếu đến đối tượng. Trong trường hợp này, các quy tắc trường cuối cùng của Java đảm bảo rằng nếu trình đọc thấy tệp đối chiếu, thì trình đọc cũng sẽ thấy các trường cuối cùng được khởi tạo. C++ không có quy tắc tương tự và con trỏ đến đối tượng thuộc sở hữu cũng không được chấp nhận vì lý do này (ngoài việc vi phạm các yêu cầu "có thể sao chép đơn giản").

Lời kết

Mặc dù tài liệu này không chỉ đề cập đến những điều cơ bản, nhưng nó cũng không đi sâu vào chi tiết. Đây là một chủ đề rất rộng và sâu. Một số lĩnh vực cần khám phá thêm:

  • Các mô hình bộ nhớ Java và C++ thực tế được thể hiện dưới dạng mối quan hệ xảy ra trước, chỉ định thời điểm hai hành động được đảm bảo xảy ra theo một thứ tự nhất định. Khi định nghĩa về tranh chấp dữ liệu, chúng ta đã nói một cách không chính thức về hai hoạt động truy cập bộ nhớ xảy ra "đồng thời". Về mặt 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 kia. Bạn nên tìm hiểu các định nghĩa thực tế về happens-before (xảy ra trước) và synchronizes-with (đồng bộ hoá với) trong Mô hình bộ nhớ Java hoặc C++. Mặc dù khái niệm trực quan về "đồng thời" thường là đủ, nhưng 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 thao tác nguyên tử được sắp xếp yếu trong C++. (Thông số kỹ thuật Java hiện tại chỉ định nghĩa lazySet() một cách rất không chính thức.)
  • Tìm hiểu những việc 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ụ hay về các phép biến đổi hợ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 bất biến trong Java và C++. (Có nhiều điều cần lưu ý hơn là chỉ "không thay đổi bất cứ điều gì sau khi xây dựng".)
  • Nắm rõ các đề xuất trong phần Đồng thời của Effective Java, 2nd Edition. (Ví dụ: bạn nên tránh gọi các phương thức được dùng để ghi đè khi ở trong một khối đồng bộ hoá.)
  • Đọc qua các API java.util.concurrent và java.util.concurrent.atomic để xem những API hiện có. Hãy cân nhắc sử dụng các chú thích đồng thời như @ThreadSafe và @GuardedBy (từ net.jcip.annotations).

Phần Tài liệu đọc thêm trong phụ lục có các đường liên kết đến tài liệu và trang web giúp làm sáng tỏ hơn những chủ đề này.

Phụ lục

Triển khai kho lưu trữ đồ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ự triển khai, nhưng cuộc thảo luận này sẽ làm sáng tỏ vấn đề.)

Đối với các loại tích hợp sẵn nhỏ như int và phần cứng được Android hỗ trợ, các lệnh tải và lưu trữ thông thường đảm bảo rằng một cửa hàng sẽ hiển thị toàn bộ hoặc không hiển thị cho một bộ xử lý khác đang tải cùng một vị trí. Do đó, một số khái niệm cơ bản về "tính nguyên tử" được cung cấp miễn phí.

Như chúng ta đã thấy trước đó, điều nà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 rằng các thao tác trong bộ nhớ sẽ hiển thị cho các quy trình khác theo thứ tự nhất quán. Hoá ra, việc sau là tự động trên phần cứng được Android hỗ trợ, miễn là chúng ta đưa ra lựa chọn sáng suốt để thực thi việc trước, vì vậy, chúng ta hầu như bỏ qua việc này ở đâ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 thứ tự và ngăn phần cứng sắp xếp lại thứ tự. Ở đây, chúng ta sẽ tập trung vào trường hợp sau.

Thứ tự bộ nhớ trên ARMv7, x86 và MIPS được thực thi bằng các lệnh "fence" (hàng rào) để ngăn chặn các lệnh theo sau hàng rào hiển thị trước các lệnh đứ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 đó có nguy cơ gây nhầm lẫn với các rào cản kiểu pthread_barrier, vốn có nhiều chức năng hơn thế này.) Ý nghĩa chính xác của các lệnh rào chắn là một chủ đề khá phức tạp, phải giải quyết cách thức tương tác giữa các đảm bảo do nhiều loại rào chắn khác nhau cung cấp và cách các rào chắn này kết hợp với các đảm bảo thứ tự khác thường do phần cứng cung cấp. Đây là thông tin tổng quan, nên chúng ta sẽ lướt qua những chi tiết này.

Loại đảm bảo thứ tự cơ bản nhất là loại được cung cấp bởi các thao tác nguyên tử memory_order_acquire và memory_order_release của C++: Các thao tác bộ nhớ trước một kho lưu trữ phát hành sẽ hiển thị sau một tải thu nạp. Trên ARMv7, điều này được thực thi bằng:

  • Trước lệnh lưu trữ là một lệnh rào chắn phù hợp. Điều này ngăn tất cả các quyền truy cập bộ nhớ trước đó được sắp xếp lại bằng lệnh lưu trữ. (Việc này cũng ngăn chặn một cách không cần thiết việc sắp xếp lại thứ tự bằng lệnh lưu trữ sau này.)
  • Làm theo lệnh tải bằng một lệnh rào chắn phù hợp, ngăn lệnh tải được sắp xếp lại bằng các quyền truy cập tiếp theo. (Và một lần nữa, việc này lại cung cấp thứ tự không cần thiết với ít nhất là các lượt tải trước đó.)

Cùng với nhau, các lệnh này đủ để sắp xếp thứ tự thu nạp/giải phóng C++. Các rào cản này là cần thiết nhưng chưa đủ đối với volatile của Java hoặc atomic nhất quán tuần tự của C++.

Để xem chúng ta cần gì khác, hãy xem xét đoạn thuật toán của Dekker mà chúng ta đã đề cập ngắn gọn trước đó. flag1 và flag2 là các biến C++ atomic hoặc Java volatile, cả hai đều có giá trị ban đầu là false.

Chuỗi 1 Chuỗi 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

Tính nhất quán tuần tự ngụ ý rằng một trong các phép gán cho flagn phải được thực thi trước và được kiểm thử trong luồng khác. Do đó, chúng ta sẽ không bao giờ thấy các luồng này thực thi "nội dung quan trọng" cùng một lúc.

Tuy nhiên, rào chắn cần thiết cho thứ tự thu nạp-giải phóng chỉ thêm rào chắn ở đầu và cuối mỗi luồng, điều này không giúp ích gì ở đây. Ngoài ra, chúng ta cần đảm bảo rằng nếu một lệnh lưu trữ volatile/atomic được theo sau bởi một lệnh tải volatile/atomic, thì hai lệnh này sẽ 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 rào chắn không chỉ trước một kho lưu trữ nhất quán tuần tự mà còn sau kho lưu trữ đó. (Điều này một lần nữa mạnh hơn nhiều so với yêu cầu, vì rào chắn này thường sắp xếp tất cả các quyền truy cập bộ nhớ trước đó theo tất cả các quyền truy cập sau này.)

Thay vào đó, chúng ta có thể liên kết rào chắn bổ sung với các tải nhất quán tuần tự. Vì các cửa hàng ít thường xuyên hơn, nên quy ước mà chúng tôi mô tả phổ biến hơn và được dùng trên Android.

Như đã thấy trong phần trước, chúng ta cần chèn một rào cản lưu trữ/tải giữa hai thao tác. Mã được thực thi trong máy ảo để truy cập không ổn định sẽ có dạng như sau:

tải không ổn định kho lưu trữ tạm thời
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Kiến trúc máy thực thường cung cấp nhiều loại rào chắn, sắp xếp các loại quyền truy cập khác nhau và có thể có chi phí khác nhau. Sự lựa chọn giữa các rào cản này là rất tinh tế và chịu ảnh hưởng của nhu cầu đảm bảo rằng các cửa hàng được hiển thị cho các lõi khác theo thứ tự nhất quán và thứ tự bộ nhớ do sự kết hợp của nhiều rào cản tạo ra một cách chính xác. Để biết thêm thông tin chi tiết, vui lòng xem trang của Đại học Cambridge có các mối liên kết đã thu thập của nguyên tử với bộ xử lý thực tế.

Trên một số cấu trúc, đặc biệt là x86, các rào cản "acquire" (thu nạp) và "release" (phát hành) là không cần thiết, vì phần cứng luôn ngầm thực thi thứ tự đầy đủ. Do đó, trên x86, chỉ có rào chắn cuối cùng (3) thực sự được tạo. Tương tự trên x86, các thao tác đọc-sửa đổi-ghi nguyên tử ngầm định bao gồm một rào cản mạnh. Do đó, các lệnh này không bao giờ yêu cầu rào chắn. Trên ARMv7, bạn cần có tất cả rào chắn mà chúng ta đã thảo luận ở trên.

ARMv8 cung cấp các lệnh LDAR và STLR trực tiếp thực thi các yêu cầu về tải và lưu trữ nhất quán tuần tự của Java volatile hoặc C++. Các phương thức này giúp tránh các quy tắc ràng buộc không cần thiết về việc sắp xếp lại thứ tự mà chúng tôi đã đề cập ở trên. Mã Android 64 bit trên ARM sử dụng các mã này; chúng tôi chọn tập trung vào vị trí rào cản ARMv7 ở đây vì nó làm sáng tỏ 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 chi tiết hoặc bao quát hơn. Các bài viết hữu ích hơn thường nằm ở gần đầu danh sách.

Mô hình nhất quán bộ nhớ dùng chung: Hướng dẫn
Được viết vào năm 1995 bởi Adve & Gharachorloo, đây là một tài liệu hay để bắt đầu nếu bạn muốn tìm hiểu sâu hơn về các mô hình nhất quán bộ nhớ.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Rào cản bộ nhớ
Bài viết ngắn gọn và súc tích tóm tắt các vấn đề.
https://en.wikipedia.org/wiki/Memory_barrier
Kiến thức cơ bản về chuỗi
Giới thiệu về lập trình đa luồng trong C++ và Java, của Hans Boehm. Thảo luận về các cuộc đua dữ liệu và phương pháp đồng bộ hoá cơ bản.
http://www.hboehm.info/c++mm/threadsintro.html
Java Concurrency In Practice (Tính đồng thời trong Java trong thực tế)
Xuất bản năm 2006, cuốn sách này đề cập đến nhiều chủ đề một cách chi tiết. 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ơ lược về mô hình bộ nhớ Java, bao gồm cả nội dung giải thích về tính năng đồng bộ hoá, biến không ổn định và việc tạo các trường cuối cùng. (Hơi cũ, đặ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
Một lời giải thích khá kỹ thuật về các vấn đề còn lại với mô hình bộ nhớ Java. Những vấn đề này không áp dụng cho các chương trình không có xung đột dữ liệu.
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à phần có tiêu đề "Memory Consistency Properties" (Thuộc tính nhất quán của bộ nhớ) giải thích các đảm bảo do nhiều lớp khác nhau đưa ra.
java.util.concurrent Tóm tắt về gói
Lý thuyết và thực hành về 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 tham chiếu trong quá trình tạo đối tượng và cung cấp các nguyên tắc cho 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 về Java: Quản lý tính biến động
Một bài viết hay mô tả những việc bạn có thể và không thể thực hiện với các trường biến động trong Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html
Khai báo "Double-Checked Locking is Broken" (Khoá kiểm tra kép bị hỏng)
Giải thích chi tiết của Bill Pugh về nhiều cách mà tính năng khoá được kiểm tra kỹ bị hỏng mà không có volatile hoặc atomic. Bao gồm C/C++ và Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[ARM] Barrier Litmus Tests and Cookbook
Nội dung thảo luận về các vấn đề của ARM SMP, được minh hoạ bằng các đoạn mã ARM ngắn. Nếu bạn thấy các ví dụ trên trang này quá chung chung hoặc muốn đọc nội dung mô tả chính thức về lệnh DMB, hãy đọc bài viết này. Tài liệu này cũng mô tả các lệnh 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). Lưu ý rằng điều này có trước ARMv8, 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. (Xem "ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile" (Tài liệu tham khảo về cấu trúc ARM® ARMv8, cho hồ sơ cấu 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ớ nhân Linux
Tài liệu về rào cản bộ nhớ nhân Linux. Bao gồm một số ví dụ hữu ích và nghệ thuật ASCII.
http://www.kernel.org/doc/Documentation/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 hoạt động nguyên tử của C++. Phiên bản này gần với tiêu chuẩn C++14, bao gồm các thay đổi nhỏ trong khu vực này từ C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(intro: 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 ("Atomics <stdatomic.h>")
Bản nháp tiêu chuẩn cho các tính năng của phép toán nguyên tử ISO/IEC 9899-201x C. Để biết thông tin chi tiết, hãy xem thêm các báo cáo lỗi 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 Sevcik và Peter Sewell về các nguyên tử C++ sang nhiều tập lệnh bộ xử lý phổ biến.
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 vấn đề loại trừ lẫn nhau trong lập trình đồng thời". Bài viết trên Wikipedia có thuật toán đầy đủ, kèm theo nội dung thảo luận về cách cập nhật thuật toán này để hoạt động với các trình biên dịch tối ưu hoá hiện đại và phần cứng SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm
Nhận xét về ARM so với Alpha và giải quyết các phần phụ thuộc
Một email trong danh sách gửi thư của arm-kernel từ Catalin Marinas. Bao gồm một bản tóm tắt hay về các phần phụ thuộc địa chỉ và kiểm soát.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
What Every Programmer Should Know About Memory (Những điều mọi lập trình viên nên biết về bộ nhớ)
Một bài viết rất dài và chi tiết về các loại bộ nhớ, đặc biệt là bộ nhớ đệm CPU, của Ulrich Drepper.
http://www.akkadia.org/drepper/cpumemory.pdf
Lý luận về mô hình bộ nhớ nhất quán yếu của ARM
Bài viết này do Chong và Ishtiaq của ARM, Ltd. viết, nhằm mô tả mô hình bộ nhớ ARM SMP một cách chặt chẽ nhưng dễ hiểu. Định nghĩa về "khả năng ghi nhận" được sử dụng ở đây là từ bài viết này. Xin nhắc lại, điều này có trước ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711
Sổ tay JSR-133 dành cho người viết trình biên dịch
Doug Lea đã viết bài này để bổ sung cho tài liệu JSR-133 (Mô hình bộ nhớ Java). Tài liệu này chứa bộ nguyên tắc triển khai ban đầu cho mô hình bộ nhớ Java mà nhiều người viết trình biên dịch đã sử dụng, đồng thời vẫn được trích dẫn rộng rãi và có khả năng cung cấp thông tin chi tiết. Rất tiếc, 4 loại rào cản được thảo luận ở đây không phù hợp với các cấu trúc được Android hỗ trợ và các mối liên kết C++11 ở trên hiện là nguồn công thức chính xác 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 viên nghiêm ngặt và hữu dụng cho bộ đa xử lý x86
Nội dung 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