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ợ 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
B = 5
reg0 = B
reg1 = A

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 AB 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ặc pthread_mutex_t) hoặ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ẽ đề 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 Java synchronized khối hoặc C++ lock_guard hoặc unique_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ường atomic 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ến volatile hoặc atomic 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 = ...
  flag = true
while (!flag) {}
... = A

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ị:
  1. 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ạo A. 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ành
    Luồng 1 Chuỗi 2
    A = ...
      flag = true
    reg0 = cờ; while (!reg0) {}
    ... = A
    Khi gỡ lỗi, bạn có thể thấy vòng lặp này tiếp tục vĩnh viễn bất kể rằng flag là đúng.
  2. 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ói java.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.
  3. 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ến atomic 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 gGlobalThingthing->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()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()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ử:

  1. reg = mValue
  2. reg = reg + 1
  3. 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 synchronizemValue++ 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:

  1. 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á.
  2. 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_acquirememory_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à hai memory_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&ltmutex> 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ặc double. Đố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ủa atomic 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đồ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.concurrentjava.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@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_acquirememory_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 đó. flag1flag2 là C++ atomic hoặc Java volatile, cả hai ban đầu đều có giá trị false.

Chuỗi 1 Luồng 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 những chỉ định để flagn 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 "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 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ói java.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ặc atomic. 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