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

Giới thiệu

SMP là viết tắt của "SMP Multi-Processor" (Bộ xử lý đa bộ xử lý đối xứng). Giải pháp này mô tả một thiết kế trong đó hai hoặc nhiều lõi CPU giống nhau dùng 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 LÊN (Uni-Processor).

Hầu hết – nếu không phải là tất cả – các thiết bị Android luôn có nhiều CPU, nhưng trước đây, chỉ có một trong số các thiết bị này được dùng để chạy ứng dụng trong khi các thiết bị khác quản lý nhiều bit phần cứng của thiết bị (ví dụ: đài phát thanh). CPU có thể có cấu trúc khác nhau và 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, điều này khiến nhà phát triển phần mềm trở nên phức tạp hơn một chút. Tình huống tương tranh trong một chương trình đa luồng có thể không gây ra sự cố hiển thị trên bộ xử lý đơn giản, nhưng có thể thường xuyên bị lỗi 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 dễ bị lỗi khi chạy trên các cấu trúc bộ xử lý khác nhau hoặc thậm chí trên các phương thức triển khai khác nhau của cùng một cấu trúc. Mã đã được kiểm thử kỹ lưỡng trên x86 có thể gây ra lỗi nghiêm trọng trên ARM. Mã có thể bắt đầu bị 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 những 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 nhất quán về bộ nhớ: Tại sao SMP lại hơi khác

Đây là thông tin tổng quan bóng, tốc độ cao về một chủ thể phức tạp. Một số khía cạnh sẽ chưa hoàn chỉnh, nhưng không có phần nào gây hiểu lầm hoặc sai trái. Như bạn sẽ thấy trong phần tiếp theo, các 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 những cách xử lý kỹ hơn về đối tượng.

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

  • Có vẻ như tất cả các thao tác đối với bộ nhớ đều thực thi lần lượt từng thao tác
  • Tất cả thao tác trong một luồng có vẻ như thực thi theo thứ tự mà chương trình của bộ xử lý đó mô tả.

Tạm thời, 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 nên không gây bất ngờ: Công cụ này dịch các bài tập trong mã nguồn để tải và lưu trữ hướng dẫn theo chính xác thứ tự tương ứng, mỗi lệnh truy cập được cung cấp một lệnh. Chúng tôi cũng sẽ giả định rằng mỗi luồng thực thi trên bộ xử lý riêng.

Nếu bạn nhìn vào một đoạn mã và thấy rằng mã đó thực hiện một số lượt đọ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 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ị biết rằng CPU đang làm bất cứ việc gì ngoài việc thực thi lệnh một cách đơn giản. (Chúng ta 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à thử nghiệm quỳ.

Dưới đây là một ví dụ đơn giản về mã chạy trên 2 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ề giấy quỳ sau này, 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". Tất cả bộ nhớ ban đầu đều bằng 0. Các hướng dẫn được thực hiện từ trên xuống dưới. Ở đây, luồng 1 lưu trữ giá trị 3 tại vị trí A và 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 viết theo thứ tự này và đọc theo thứ tự khác.)

Luồng 1 và luồng 2 được giả định sẽ thực thi trên nhiều lõi CPU. Bạn nên luôn đưa ra giả định này khi xem xét 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ý 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ể (chuỗi 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 trường hợp mà chúng ta thấy B=5 trước khi thấy cửa hàng cho A, thì việc đọc hoặc ghi phải xảy ra không đúng thứ tự. Trên một máy nhất quán tuần tự, điều đó không thể xảy ra.

Các đơn vị xử lý đơn lẻ (bao gồm cả x86 và ARM) thường nhất quán về mặt 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 các luồ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ường, phần cứng sẽ lưu trữ bộ đệm trên bộ nhớ để chúng không tiếp cận ngay lập tức với bộ nhớ và hiển thị với các lõi khác.

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

Cho đến nay, chúng tôi đã không thực sự giả định rằng chỉ phần cứng mới có tác dụng sắp xếp lại 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 tôi, 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, 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 A lại. Trong cả hai trường hợp, các tải cho reg0 và reg1 có thể được sắp xếp lại.

Việc sắp xếp lại thứ tự 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) đều được cho phép vì việc này không ảnh hưởng đến việc thực thi của một luồng đơn lẻ và có thể cải thiện đáng kể hiệu suất. Như đã thấy, nếu cẩn thận, chúng ta cũng có thể ngăn tình trạng 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 lẻ, trình biên dịch vẫn có thể sắp xếp lại các tải thành reg0 và reg1 trong ví dụ của chúng tôi và Thread 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ự, có thể chúng ta sẽ không bao giờ quan sát được vấn đề này. Trên hầu hết các SMP ARM, ngay cả khi không sắp xếp lại trình biên dịch, vẫn có thể thấy việc sắp xếp lại thứ tự, có thể là 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 lập trình bằng ngôn ngữ tập hợp, SMP thường giúp tăng khả năng bạn gặp phải các sự cố.

Lập trình không cần cuộc đua dữ liệu

Rất may là thường có một cách dễ dàng để không 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 thường, bạn có thể quên tất cả các phần trước đó ngoại trừ phần "tính nhất quán tuần tự". 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 phong cách lập trình "không có cuộc đua dữ liệu". Miễn là bạn hứa không giới thiệu "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, nếu không thì trình biên dịch và phần cứng hứa hẹn 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 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 tuân theo 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. Điều đó rất giống với việc nói với bạn rằng xúc xích là một món ăn ngon và ngon miệng, miễn là bạn hứa sẽ không đến thăm nhà máy sản xuất xúc xích. Cuộc đua dữ liệu là sự thật không hay 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 2 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 đó. "dữ liệu thông thường" ở đây có nghĩa là một đối tượng cụ thể không phải là đối tượng đồng bộ hoá dành cho việc giao tiếp luồng. mutex, biến điều kiện, biến Java 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 các biến này được phép chạy đua. Trên thực tế, chúng dùng để ngăn cuộc đua dữ liệu trên các đối tượng khác.

Để xác định xem 2 luồng có truy cập đồng thời 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 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 và ban đầu là giá trị false:

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 sẽ được đánh giá là false và không có biến nào được cập nhật. Do đó, không thể có cuộc đua dữ liệu. Bạn không cần phải suy nghĩ về những gì có thể xảy ra nếu nội dung tải từ A và cửa hàng 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 Chuỗi 1 bằng cách viết lại thành "B = true; if (!A) B = false". Điều đó sẽ giống như việc làm xúc xích giữa thành phố vào ban ngày rộng rãi.

Cuộc đua dữ liệu được định nghĩa chính thức trên các loại tích hợp cơ bản như số nguyên và tham chiếu hoặc con trỏ. Việc chỉ định cho một int trong khi đồng thời đọc mã đó 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 Bộ sưu tập Java đều được viết để cho phép bạn giải thích về các cuộc đua dữ liệu ở cấp thư viện. Các phương thức này hứa hẹn sẽ không tạo ra các cuộc đ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, ít nhất một trong số đó cập nhật vùng chứa đó. Việc cập nhật set<T> trong một luồng trong khi đọc đồng thời trong luồng khác cho phép thư viện triển khai một cuộc đua dữ liệu, do đó có thể được coi là "cuộc đua dữ liệu cấp thư viện" một cách không chính thức. 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 luồng khác sẽ không dẫn đến cuộc đua dữ liệu vì thư viện hứa hẹn sẽ không tạo ra cuộc đua dữ liệu (cấp thấp) trong trường hợp đó.

Thông thường, việc 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ể tạo ra cuộc đua 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 liền kề của các trường bit 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 trình tự như vậy được coi là truy cập vào tất cả các trường đó nhằm mục đích xác định sự tồn tại của 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 thể cập nhật từng bit riêng lẻ nếu 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ự nào.

Tránh các cuộc đua 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 các cuộc đua 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 các khối
mutex (C++11 std::mutex, hoặc pthread_mutex_t) hoặc 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 các phương tiện này và các trang thiết bị tương tự khác chung là "khoá". Luôn thu nạp 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 cấu trúc đó sau đó, giúp ngăn chặn các cuộc đua dữ liệu khi truy cập vào cấu trúc dữ liệu. Ngoài ra, tính năng này cũng đảm bảo rằng các bản cập nhật và quyền truy cập đều có tính nguyên tử, tức là không thể chạy một nội dung cập nhật nào khác cho cấu trúc dữ liệu ở giữa quá trình cập nhật. Cho đến nay, đây thực sự là công cụ phổ biến nhất để ngăn chặn các cuộc đua dữ liệu. Việc sử dụng các khối Java synchronized hoặc C++ lock_guard hoặc unique_lock giúp đảm bảo khoá được huỷ đúng cách trong trường hợp ngoại lệ.
Biến số/nguyên tử
Java cung cấp các trường volatile hỗ trợ truy cập đồng thời mà không tạo ra các cuộc đua 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 khoá này thường khó sử dụng hơn khoá vì chúng chỉ đảm bảo rằng quyền truy cập riêng lẻ vào một biến duy nhất là không thể phân chia. (Trong C++, thao tác này thường mở rộng các thao tác đọc-sửa đổi-ghi đơn giản, như số gia. Java yêu cầu các lệnh gọi phương thức đặc biệt để làm điều đó.) Không giống như khoá, bạn không thể sử dụng trực tiếp biến volatile hoặc atomic để ngăn các luồng khác can thiệp vào những trình tự mã dài hơn.

Điều quan trọng bạn 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 việc xảy ra xung đột dữ liệu, mặc dù mã cũ thường dùng mã này để giải quyết việc thiếu đối tượng atomic. Bạn không nên sử dụng phương pháp 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. C++ volatile dành cho các thanh ghi thiết bị và các mục tương tự.

Bạn có thể dùng biến atomic C/C++ hoặc biến Java volatile để ngăn cuộc đua 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 có giá trị false, thì đoạn mã sau đây là không có cuộc đua dữ liệu:

Chuỗi 1 Chuỗi 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 diễn ra sau (chứ không đồng thời với) A trong Luồng 1. Do đó, không có cuộc đua dữ liệu trên A. Cuộc đua trên flag không được tính là một cuộc đua dữ liệu, vì 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".

Việc triển khai là bắt buộc để ngăn chặn hoặc ẩn việc sắp xếp lại bộ nhớ đủ để giúp mã như kiểm thử giấy quỳ trước đó hoạt động như dự kiến. Điều này thường khiến quyền truy cập vào bộ nhớ nguyên tử/không ổn định sẽ tốn kém đáng kể so với quyền truy cập thông thường.

Mặc dù ví dụ trước là không có cuộc đua dữ liệu, nhưng các 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 một giải pháp tốt hơn mà không cần chờ vòng lặp trong khi làm tiêu hao pin.

Khi việc sắp xếp lại bộ nhớ hiển thị

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

Việc không nên làm ở C

Ở đây, chúng tôi trình bày một số ví dụ về mã không chính xác cùng với các cách đơn giản để sửa lỗi. Trước khi làm điều đó, chúng ta cần thảo luận về việc sử dụng tính năng ngôn ngữ cơ bản.

C/C++ và "volatile"

Các phần khai báo volatile trong C và C++ là một công cụ phục vụ 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á các quyền truy cập volatile (biến động). Điều này có thể hữu ích khi mã truy cập vào các đăng ký thiết bị phần cứng, bộ nhớ được ánh xạ tới nhiều vị trí hoặc liên quan đến setjmp. Nhưng volatile trong C và C++, không giống như Java volatile, không được thiết kế để giao tiếp theo luồng.

Trong C và 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 đảm bảo 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 bộ xử lý đơn 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 C volatile thậm chí còn kém hữu ích hơn trong môi trường SMP đa 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 từ khoá đó.

Nhiều mã C và C++ cũ vẫn lạm dụng volatile để giao tiếp với luồng. Thao tác này thường hoạt động chính xác đối với dữ liệu phù hợp với một thanh ghi máy, miễn là dữ liệu này đượ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, hệ thống không đảm bảo 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ì sử dụng toán tử nguyên tử, nhưng chúng tôi sẽ sử dụng toán tử thứ hai để minh hoạ cách sử dụng các thao tác này trong 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 tôi phân bổ 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 "phát hành" cấu trúc đó bằng cách lưu trữ cấu trúc đó trong một biến toàn cục. Tại thời điểm đó, bất kỳ luồng nào khác đều có thể nhìn thấy luồng này, nhưng không có vấn đề gì vì nó được khởi động hoàn toàn phải không?

Vấn đề là bạn có thể quan sát được cửa hàng trong gGlobalThing trước khi khởi chạy các trường, thường là do trình biên dịch hoặc bộ xử lý đã sắp xếp lại các cửa hàng thành gGlobalThingthing->x. Một luồng khác đọc từ thing->x có thể thấy giá trị 5, 0 hoặc thậm chí là 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 useGlobalThing(), thì hệ thống có thể đọc gGlobalThing trong khi 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 rằng các lượt ghi sẽ hiển thị với các luồng khác theo thứ tự thích hợp. Đồng thời, thay đổi 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 ít có khả năng xảy ra trên phần cứng Android thực. Ví dụ: nó đảm bảo rằng chúng ta không thể thấy con trỏ gGlobalThing chỉ mới được viết một phần.

Việc không nên làm trong Java

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

Về mặt kỹ thuật, Java không yêu cầu mã phải không có cuộc đua dữ liệu. Ngoài ra, 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ó các cuộc đua dữ liệu. Tuy nhiên, việc viết mã như vậy là cực kỳ khó khăn và chúng tôi chỉ thảo luận ngắn gọn dưới đây. Một vấn đề tệ hơn nữa là các chuyên gia chỉ định ý nghĩa của mã như vậ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 mã không có cuộc đua dữ liệu.)

Hiện tại, chúng ta sẽ tuân thủ mô hình không có đường đua dữ liệu (data-race-less) mà Java cung cấp cơ bản tương tự như C và C++. Xin nhắc lại, ngôn ngữ này cung cấp một số dữ liệu nguyên gốc giúp thư giãn một cách 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 như với C và C++, chúng ta sẽ tạm thời bỏ qua những phần tử này.

Từ khoá "đồng bộ hoá" và "volatile" của Java

Từ khoá "đồng bộ hoá" 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 "trình giám sát" liên kết có thể dùng để cung cấp quyền truy cập độc quyền lẫn nhau. Nếu 2 luồng cố gắng "đồng bộ hoá" trên cùng một đối tượng, thì một trong 2 luồng sẽ đợi 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à giao diện tương tự của atomic<T> của C++11. Cho phép truy cập đồng thời vào các trường volatile và sẽ không dẫn đến cuộc đua dữ liệu. Khi bỏ qua lazySet() và các cuộc đua dữ liệu, máy ảo Java có nhiệm vụ đảm bảo rằng kết quả vẫn xuất hiện nhất quán tuần tự.

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

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++ sẽ giống như x = x + 1; nó sẽ thực hiện tải nguyên tử, tăng kết quả và sau đó thực hiện lưu trữ nguyên tử. Không giống như C++, tổng thể gia số không phải là số nguyên tử. Thay vào đó, các toán tử tăng nguyên tử sẽ do java.util.concurrent.atomic cung cấp.

Ví dụ

Dưới đây là cách triển khai đơn giản và không chính xác của bộ đếm đơn điệu: (Lý thuyết và thực hành 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()incr() được gọi từ nhiều luồng và chúng ta muốn chắc chắn 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à ba toán tử:

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

Nếu 2 luồng thực thi trong incr() cùng lúc, thì một trong các bản cập nhật có thể bị mất. Để tạo nguyên tử tăng, chúng ta cần khai báo incr() "được đồng bộ hoá".

Tuy nhiên, tính năng này vẫn chưa hoạt động tốt, đặc biệt là trên SMP. Vẫn còn một cuộc đua dữ liệu nữa, trong đó get() có thể truy cập vào mValue đồng thời với incr(). Theo các quy tắc Java, lệnh gọi get() có thể có vẻ như được sắp xếp lại so với mã khác. Ví dụ: nếu chúng ta đọc 2 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 tôi đã sắp xếp lại đã được sắp xếp lại, do phần cứng hay 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() sẽ được đồng bộ hoá. Với thay đổi này, mã hiển nhiên là chính xác.

Thật không may, chúng tôi đã giới thiệu khả năng tranh chấp khoá, điều này có thể ảnh hưởng đến hiệu suất. Thay vì khai báo get() được đồng bộ hoá, chúng ta có thể khai báo mValue với 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ử nguyên tử đơn lẻ.) Điều này cũng giúp tránh tất cả các cuộc đua dữ liệu, vì vậy, tính nhất quán về tuần tự sẽ được giữ nguyên. incr() sẽ chậm hơn một chút, vì nó làm phát sinh cả mức hao tổn giám sát vào/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, đây vẫn sẽ là chiến thắng nếu đọc được số lượt ghi nhiều hơn đáng kể. (Hãy xem thêm AtomicInteger để biết cách xoá hoàn toàn khối được đồng bộ hoá.)

Sau đây là một ví dụ khác, tương tự như các ví dụ C ở trên:

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ó cuộc đua dữ liệu trên sGoodies. Do đó, bạn có thể quan sát hoạt động chỉ định sGoodies = goods trước khi khởi tạo các trường trong goods. Nếu bạn khai báo sGoodies bằng từ khoá volatile, 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.

Lưu ý rằng chỉ bản thân tham chiếu sGoodies là không ổn định. Quyền truy cập vào các trường bên trong thì không. Khi sGoodiesvolatile và thứ tự bộ nhớ được giữ nguyên đúng cách, thì bạn không thể truy cập đồng thời vào các trường. Câu lệnh z = sGoodies.x sẽ thực hiện tải không biến động MyClass.sGoodies, theo sau là tải bất biến là 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ỳ 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à lỗi “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ể của đối tượng Helper liên kết với một thực thể của MyClass. Chúng ta chỉ phải tạo phương thức một lần, vì vậy, chúng ta sẽ tạo và trả về phương thức này thông qua một hàm getHelper() chuyên biệt. Để tránh tình huống 2 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 tôi không muốn trả chi phí cho khối "đồng bộ hoá" trên mọi lệnh gọi, vì vậy, chúng tôi chỉ thực hiện phần đó nếu helper hiện đang 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 điều này có thể không thành công, 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 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 phần cứng hoặc trình biên dịch sắp xếp lại cửa hàng vào helper bằng những nội dung đó đối với các trường x/y. Một luồng khác có thể tìm thấy helper khác rỗng nhưng các trường của luồng này 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 đường liên kết "Khai báo "Đã kiểm tra hai lần" bị hỏng" trong phần phụ lục để biết thêm chi tiết hoặc Mụ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ả, phiên bản thứ 2 của Josh Bloch.

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

  1. Thực hiện thao tác đơn giản và xoá dấu kiểm bên ngoài. Điều này đảm bảo rằng chúng tôi 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 biến động helper. Với một thay đổi nhỏ này, mã trong Example J-3 sẽ hoạt động chính xác trên Java 1.5 trở lên. (Bạn nên dành một phút để tự thuyết phục mình 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
        }
    }
}

Xem xét useValues(), nếu Luồng 2 chưa quan sát thấy bản cập nhật lên vol1, thì không thể biết liệu data1 hoặc data2 đã được đặt hay chưa. Khi thấy nội dung cập nhật thành vol1, công cụ này biết rằng data1 có thể được truy cập an toàn và đọc chính xác mà không cần dẫn đến cuộc đua dữ liệu. Tuy nhiên, chúng tôi 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 cửa hàng biến động.

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 xung đột với nhau. Việc này không đảm bảo sẽ tạo được hướng dẫn của hàng rào bộ nhớ máy. Bạn có thể dùng hàm này để ngăn cuộc đua 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.

Điều cầ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 toán tử pthread tương ứng. Chúng bao gồm hàng rào bộ nhớ thích hợp, cung cấp hành vi chính xác (theo tuần tự nhất quán trừ phi 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 nhớ sử dụng chúng đúng cách. Ví dụ: hãy nhớ rằng các khoảng thời gian chờ biến điều kiện có thể trả về liên tục mà không cần được báo hiệu và 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ư một bộ đếm. Việc khoá và mở khoá mutex pthread yêu cầu một thao tác nguyên tử duy nhất và thường có chi phí thấp hơn một lần bỏ lỡ bộ nhớ đệm nếu không có tranh chấp, do đó, 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 hoạt động nguyên tử. Thiết kế không bị khoá cho các cấu trúc dữ liệu không quan trọng đòi hỏi phải được quan tâm nhiều hơn để đảm bảo rằng các hoạt động cấp cao hơn trên cấu trúc dữ liệu sẽ xuất hiện ở mức nguyên tử (tổng thể, chứ không chỉ các phần rõ ràng là nguyên tử).

Nếu bạn sử dụng toán tử nguyên tử, việc nới lỏng việc sắp xếp bằng memory_order... hoặc lazySet() có thể mang lại những lợi thế về hiệu suất, nhưng đòi hỏi sự hiểu biết chuyên sâu hơn so với những gì chúng tôi đã truyền đạt cho đến nay. Sau khi thực 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. Hãy tránh những cách này nếu có thể. Nếu các trường hợp sử dụng của bạn không giống chính xác với một trong những trường hợp ở phần tiếp theo, hãy đảm bảo rằng 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 luồng trong C/C++.

Trong Java, các vấn đề đồ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ã này được viết đúng cách và được kiểm thử tốt trên SMP.

Có lẽ điều an toàn nhất mà bạn có thể làm là khiến cho đối tượng của bạn trở nên bất biến. Các đối tượng từ các lớp như Chuỗi và Số nguyên của Java chứa dữ liệu không thể thay đổi được sau khi tạo một đối tượng, điều này giúp tránh mọi nguy cơ cuộc đua dữ liệu trên các đối tượng đó. Cuốn sách Hiệu quả Java, Ấn bản thứ 2 có các hướng dẫn cụ thể trong “Mục 15: Giảm thiểu khả năng thay đổi”. Lưu ý đặc biệt là tầm quan trọng của việc khai báo các trường Java là "chính thức" (Bloch).

Ngay cả khi một đối tượng là không thể thay đổi, hãy nhớ rằng việc giao tiếp đối tượng đó với một luồng khác mà không có bất kỳ loại đồng bộ hoá nào sẽ là một cuộc đua dữ liệu. Điều này đôi khi có thể chấp nhận được trong Java (xem bên dưới), nhưng đòi hỏi phải cẩn thận và có thể dẫn đến mã giòn. Nếu đây không phải là điều quan trọng về hiệu suất, hãy thêm nội dung khai báo volatile. Trong C++, việc giao tiếp một con trỏ hoặc tham chiếu đến một đối tượng không thể thay đổi mà không được đồng bộ hoá đúng cách (chẳng hạn như mọi cuộc đua dữ liệu) sẽ bị coi là lỗi. Trong trường hợp này, có khả năng sẽ dẫn đến các sự cố không liên tục, chẳng hạn như luồng nhận có thể thấy một con trỏ bảng phương thức chưa 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 không thể thay đổi đều không phù hợp, thì bạn nên sử dụng câu lệnh synchronized của Java hoặc C++ lock_guard / unique_lock để 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 vào. Nếu mutex không hoạt động trong 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 cẩn trọng để hiểu rõ sự 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 những lỗi bí ẩn liên quan đến việc tối ưu hoá trình biên dịch và chức năng tích hợp SMP.

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 đối tượng đó 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 làm theo lời khuyên "không chạy đua dữ liệu" trong Java. Tuy nhiên, đây luôn là một lời khuyên hữu ích và điều quan trọng là 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 rất quan trọng, đồng thời mã không đáng tin cậy có thể gây ra một cuộc đua dữ liệu bằng cách truy cập vào tham chiếu đối tượng "bị rò rỉ" đó. Ngoài ra, bạn cũng nên bỏ qua các 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 nội dung (Kỹ thuật xây dựng an toàn trong Java) để biết thông tin chi tiết

Thông tin khác 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 việc đảm bảo tính nhất quán tuần tự cho các chương trình không có cuộc đua dữ liệu. Các đối số memory_order_relaxed, memory_order_acquire (chỉ tải) và memory_order_release(chỉ tải) tường minh cho các thao tác nguyên tử đều đưa ra đảm bảo yếu hơn so với memory_order_seq_cst mặc định, thường là ngầm ẩn. memory_order_acq_rel đảm bảo cả memory_order_acquirememory_order_release cho các thao tác ghi sửa đổi đọc nguyên tử. memory_order_consume chưa được chỉ định hoặc triển khai đầy đủ để hữu ích. Hiện tại, bạn nên bỏ qua memory_order_consume.

Các phương thức lazySet trong Java.util.concurrent.atomic tương tự như các phương thức memory_order_release trong C++. Đôi khi, các biến thông thường của Java được dùng để thay thế cho các quyền truy cập memory_order_relaxed, mặc dù thực tế các biến này thậm chí còn yếu hơn. Không giống như C++, không có cơ chế thực nào để truy cập không theo thứ tự vào các biến được khai báo là volatile.

Thường thì bạn nên tránh các API này trừ phi có lý do thúc đẩy hiệu suất để sử dụng chúng. Trên các cấu trúc máy có thứ tự yếu như ARM, việc sử dụng các cấu trúc này thường sẽ lưu theo thứ tự vài chục chu kỳ máy cho mỗi hoạt động nguyên tử. Trên x86, phần thắng về hiệu suất chỉ giới hạn ở các cửa hàng và có thể sẽ ít đáng chú ý hơn. Về mặt trực quan, 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 yếu tố hạn chế nhiều hơn.

Ngữ nghĩa đầy đủ của nguyên tử có thứ tự yếu rất phức tạp. Nhìn chung, các định dạng này yêu cầu sự hiểu biết chính xác về các quy tắc ngôn ngữ, còn chúng tôi sẽ không trình bày ở đây. Ví dụ:

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

Để nhắc nhở về điều đó, ở đây chúng tôi đưa ra một số thành ngữ có vẻ liên quan đến nhiều trường hợp sử dụng cho các nguyên tử có thứ tự yếu. Nhiều thuật toán trong số này chỉ áp dụng cho C++.

Đường truy cập không dành cho đua xe

Khá phổ biến là một biến có dạng nguyên tử vì biến đôi khi được đọc đồng thời với một lượt ghi, nhưng không phải tất cả quyền truy cập đều gặp vấn đề này. Ví dụ: một biến có thể cần phải là nguyên tử vì 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ể thực hiện được vì không thể ghi đồng thời. Trong trường hợp như vậy, bạn có thể chú thích quyền truy cập không chạy đua (tải trong trường hợp này) bằng memory_order_relaxed mà không cần 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 qua các luồng khác và memory_order_relaxed chỉ định rằng về cơ bản, bạn không cần thực thi thêm quy tắc ràng buộc nào về thứ tự cho quyền truy cập nguyên tử.

Không có thực tế tương tự như vậy trong Java.

Không dựa vào kết quả để xác định độ chính xác

Khi chúng ta chỉ sử dụng hoạt động tải đua xe để tạo gợi ý, thì thường thì bạn không nên thực thi bất kỳ thứ tự bộ nhớ nào cho tải đó. Nếu giá trị không đáng tin cậy, chúng tôi 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 đó, sẽ không có vấn đề gì nếu thứ tự bộ nhớ không được đảm bảo và tải được cung cấp bằng đối số memory_order_relaxed.

Một ví dụ phổ biến của vấn đề này là việc sử dụng C++ compare_exchange để thay thế x bằng f(x) một cách nguyên tử. Tải ban đầu x để tính toán f(x) không cần đáng tin cậy. Nếu chúng tôi đưa ra kết quả sai, compare_exchange sẽ không hoạt động và chúng tôi sẽ thử lại. Việc tải ban đầu của x sử dụng đối số memory_order_relaxed là không có vấn đề gì; chỉ thứ tự bộ nhớ cho compare_exchange thực tế là quan trọng.

Đã sửa đổi nguyên tử nhưng dữ liệu chưa đọc

Đôi khi, dữ liệu được sửa đổi song song qua nhiều luồng, nhưng không được kiểm tra cho đến khi quá trình tính toán song song hoàn tất. Ví dụ điển hình cho trường hợp này là bộ đếm được tăng nguyên tử (ví dụ: sử dụng fetch_add() trong C++ hoặc atomic_fetch_add_explicit() trong C) theo 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 hoàn tất mọi quá trình cập nhật.

Trong trường hợp này, không có cách nào để biết liệu các lượt truy cập vào dữ liệu này có được sắp xếp lại hay không. Do đó, mã C++ có thể sử dụng đối số memory_order_relaxed.

Một ví dụ phổ biến cho trường hợp này là bộ đếm sự kiện đơn giản. Vì trường hợp này rất phổ biến, nên bạn cần lưu ý một vài điểm về 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 đề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 có một luồng mới truy cập vào bộ đếm. Nếu việc cập nhật diễn ra thường xuyên và luân phiên giữa các luồng, thì sẽ nhanh hơn nhiều để tránh việc cập nhật bộ đếm dùng chung mọi lúc, chẳng hạn bằng cách sử dụng bộ đếm 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: Bạn có thể đọc đồng thời các giá trị gần đúng và không đáng tin cậy trong khi các giá trị này đang được cập nhật, với tất cả thao tác sử dụng memory_order_relaxed. Nhưng bạn phải coi các giá trị thu được là hoàn toàn không đáng tin cậy. Việc số lượng dường như đã tăng một lần không có nghĩa là một luồng khác có thể được tính để đạt đến điểm thực hiện gia tăng. Thay vào đó, giá trị gia tăng này có thể đã được sắp xếp lại bằng mã cũ hơn. (Đố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ề giá trị nhỏ hơn lần tải trước đó trong cùng một luồng. Trừ trường hợp tất nhiên, bộ đếm bị tràn.)
  • Thông thường, bạn sẽ tìm thấy mã cố gắng tính toán giá trị bộ đếm gần đúng bằng cách thực hiện đọc và ghi riêng lẻ nguyên tử (hoặc không), nhưng không tăng như một nguyên tử. Đối số thông thường là hàm này "đủ gần" đối với bộ đếm hiệu suất hoặc các mục tương tự. Thường thì không. Khi các lần cập nhật đủ thường xuyên (trường hợp bạn có thể quan tâm), thì một phần lớn số lượng thường sẽ bị mất. Trên thiết bị 4 nhân, số lượng thông thường có thể bị mất hơn một nửa. (Bài tập dễ dàng: xây dựng tình huống 2 luồng, trong đó bộ đếm được cập nhật 1 triệu lần, nhưng giá trị bộ đếm cuối cùng là 1.)

Giao tiếp gắn cờ đơn giản

Lưu trữ memory_order_release (hay 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ẽ tuân theo mọi kho lưu trữ (thông thường hoặc nguyên tử) đứng trước kho lưu trữ memory_order_release. Ngược lại, mọi lượt tải trước memory_order_release sẽ không ghi nhận bất kỳ cửa hàng nào tuân theo lượt tải memory_order_acquire. Không giống như memory_order_relaxed, tuỳ chọn này cho phép sử dụng các thao tác nguyên tử như vậy để truyền đạt tiến trình của một luồng với một luồng khác.

Ví dụ: chúng tôi có thể viết lại ví dụ về khoá được kiểm tra kỹ ở trên trong C++ dưới dạng

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;
    }
};

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

Có thể hình dung 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 các lệnh gọi get() thuần tuý.

Trong cả hai trường hợp, hiệu suất của chúng tôi tập trung vào đường dẫn khởi chạy ít có khả năng ảnh hưởng đến hiệu suất. Một thoả thuận 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;
    }

Phương thức 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ó thể sẽ tạo cùng một mã trên các cấu trúc hiện tại được Android hỗ trợ dưới dạng một tham chiếu thuần tuý (nhất quán tuần tự) đến helper. Cách tối ưu hoá thực sự mang lại lợi ích lớn nhất ở đây có thể là việc ra mắt myHelper để loại bỏ tải thứ hai, mặc dù một trình biên dịch trong tương lai có thể tự động thực hiện việc đó.

Thứ tự mua/phát hành không ngăn việc cửa hàng bị trì hoãn rõ ràng, cũng như không đảm bảo rằng các cửa hàng hiển thị theo thứ tự nhất quán cho các luồng khác. Kết quả là, nó không hỗ trợ một mô hình lập trình phức tạp nhưng khá phổ biến được minh hoạ qua 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ờ biểu thị rằng chúng muốn thực hiện việc gì đó; nếu một luồng t thì nhận thấy rằng không có luồng nào khác đang cố thực hiện một việc gì đó, nó có thể tiến hành một cách an toàn, biết rằng sẽ không có can thiệp. Không thể tiến hành luồng nào khá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 theo thứ tự thu nạp/phát hành, vì điều đó không ngăn việc hiển thị cờ của luồng cho những người khác muộn, sau khi họ đã tiến hành sai. memory_order_seq_cst mặc định ngăn chặn điều này.

Trường không thể thay đổi

Nếu một trường đối tượng được khởi động trong lần sử dụng đầu tiên và sau đó không bao giờ thay đổi, thì bạn có thể khởi chạy rồi đọc trường đó bằng cách sử dụng các quyền truy cập có thứ tự yếu. Trong C++, bạn có thể khai báo thuộc tính này là atomic và được truy cập bằng memory_order_relaxed hoặc trong Java. Bạn có thể khai báo lớp này mà không cần volatile và có thể truy cập mà không cần các biện pháp đặc biệt. Điều này đòi hỏi tất cả các yêu cầu lưu giữ dữ liệu sau đây:

  • Từ giá trị của trường, bạn có thể biết liệu trường đó đã được khởi chạy hay chưa. Để truy cập vào trường này, giá trị kiểm thử và trả về của đường dẫn nhanh chỉ nên đọc trường một lần. Trong Java, cái sau là điều cần thiết. Ngay cả khi các kiểm thử trường đã được khởi tạo, lần tải thứ hai vẫn 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à tải tiếp theo đều phải có tính nguyên tử, trong đó nội dung 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++, bạn cần phải chỉ định ở cấp nguyên tử; việc xây dựng lớp này tại chỗ sẽ không hoạt động vì quá trình tạo atomic không phải ở cấp nguyên tử.
  • Việc 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 bắt nguồn từ yêu cầu "có thể sao chép không đáng kể" áp dụng cho tất cả các kiểu nguyên tử; các loại có con trỏ sở hữu lồng nhau sẽ cần phải giải phóng hàm khởi tạo bản sao và sẽ không dễ dàng sao chép được. Đối với Java, một số kiểu tham chiếu nhất định được chấp nhận:
  • Các tệp tham chiếu Java bị giới hạn ở các loại không thể thay đổi chỉ chứa các trường cuối cùng. Hàm khởi tạo của loại dữ liệu không thể thay đổi không được phát hành tệp 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 người đọc nhìn thấy tệp tham chiếu, thì họ cũng sẽ thấy các trường cuối cùng đã khởi chạy. C++ không có các quy tắc tương tự như các quy tắc này và con trỏ đến các đối tượng do bạn 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 không đáng kể").

Ghi chú kết thúc

Mặc dù không chỉ đơn thuần làm xước bề mặt, tài liệu này không quản lý nhiều hơn một thước đo nông. Đây là một chủ đề rất rộng và chuyên sâu. Một số khía cạnh cần tìm hiểu thêm:

  • Các mô hình bộ nhớ Java và C++ thực tế được biểu thị theo mối quan hệ xảy ra trước, chỉ định thời điểm hai thao tác được đảm bảo xảy ra theo thứ tự nhất định. Khi xác định một cuộc đua dữ liệu, chúng tôi đã nói một cách không chính thức về việc 2 lượt truy cập vào bộ nhớ diễn ra "cùng một lúc". Về cơ bản, điều này không được định nghĩa là một sự kiện không xảy ra trước sự kiện còn lại. Chúng tôi hướng dẫn bạn tìm hiểu các định nghĩa thực tế về xảy ra trướcđồ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à đủ tốt, 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 toán tử nguyên tử có thứ tự yếu trong C++. (Thông số kỹ thuật của Java hiện tại chỉ xác định rất không chính thức lazySet().)
  • 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ụ hay về các biế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 thể thay đổi trong Java và C++. (Ngoài việc "không thay đổi bất cứ điều gì sau khi xây dựng"), bạn còn có thể thực hiện nhiều việc khác.)
  • Nội bộ hoá các đề xuất trong mục Đồng thời của Java hiệu quả, Phiên bản thứ 2. (Ví dụ: bạn nên tránh gọi các phương thức có nghĩa là sẽ bị ghi đè khi ở bên trong một khối được đồng bộ hoá.)
  • Đọc qua các API java.util.concurrentjava.util.concurrent.atomic để xem các API có sẵn. Hãy cân nhắc sử dụng các chú giải đồng thời như @ThreadSafe@GuardedBy (từ net.jcip.hoặc).

Phần Đọc thêm trong phụ lục có các đường liên kết đến tài liệu và trang web sẽ cung cấp thông tin rõ hơn về những chủ đề này.

Phụ lục

Triển khai cửa hàng đồng bộ hoá

(Đây sẽ không phải là điều mà hầu hết các lập trình viên tự triển khai, nhưng cuộc thảo luận đã giúp bạn sáng tỏ.)

Đối với các loại tích hợp nhỏ như int và phần cứng được Android hỗ trợ, hướng dẫn tải và lưu trữ thông thường đảm bảo rằng 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 tải cùng một vị trí. Do đó, một số khái niệm cơ bản về "nguyên tử" được cung cấp miễn phí.

Như chúng ta đã thấy trước đây, như vậy là chưa đủ. Để đảm bảo tính nhất quán về mặt 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 hoạt động của bộ nhớ hiển thị cho các quy trình khác theo một thứ tự nhất quán. Hoá ra phiên bản thứ hai sẽ tự động chạy trên phần cứng được Android hỗ trợ, miễn là chúng tôi đưa ra các lựa chọn sáng suốt về việc thực thi phiên bản trước đó. Vì vậy, phần lớn chúng tôi sẽ bỏ qua nó ở đây.

Thứ tự của các thao tác của 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 tôi tập trung vào khía cạnh thứ hai.

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

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

  • Trước hướng dẫn tại cửa hàng bằng hướng dẫn về hàng rào phù hợp. Thao tác này giúp tránh 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 lưu trữ. (Việc này cũng không cần thiết ngăn việc đặt hàng lại sau này hướng dẫn trong cửa hàng.)
  • Làm theo lệnh tải cùng với một lệnh hàng rào phù hợp, tránh sắp xếp lại tả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à phải tải sớm hơn.)

Tổng cộng những dữ liệu này đủ để sắp xếp theo thứ tự thu nạp/phát hành C++. Các hàm này cần thiết nhưng chưa đủ đối với Java volatile hoặc C++ nhất quán về mặt tuần tự atomic.

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

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 nhiệm vụ gán cho flagn phải được thực thi trước tiên và được kiểm thử xem 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 đồng thời "nội dung quan trọng".

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

tải biến động cửa hàng dễ biến động
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Các cấu trúc máy thực thường cung cấp nhiều loại hàng rào, sắp xếp theo thứ tự 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 cách này rất tinh vi và bị ảnh hưởng bởi nhu cầu đảm bảo rằng các cửa hàng hiển thị với các lõi khác theo một thứ tự nhất quán, và rằng thứ tự bộ nhớ do việc kết hợp nhiều hàng rào áp đặt sẽ kết hợp 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 với các ánh xạ nguyên tử được thu thập đến bộ xử lý thực tế.

Trên một số cấu trúc, đáng chú ý là x86, các rào cản "thu nạp" và "phát hành" là không cần thiết vì phần cứng luôn ngầm thực thi đầy đủ thứ tự. Do đó, trên x86, chỉ có hàng rào 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 ẩn bao gồm một hàng rào chắc chắn. Do đó, các hoạt động này không bao giờ cần có 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 bắt buộc.

ARMv8 cung cấp các lệnh LDAR và STLR để trực tiếp thực thi các yêu cầu của Java volatile hoặc C++ (có thể tải và lưu trữ nhất quán) một cách tuần tự. Đ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 những tính năng này; chúng tôi chọn tập trung vào vị trí hàng rào ARMv7 tại đây vì mã này giúp làm rõ 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 có chiều sâu và độ bao quát hơn. Các bài viết thường hữu ích hơn ở gần đầu danh sách.

Mô hình nhất quán về bộ nhớ dùng chung: Hướng dẫn
Được viết vào năm 1995 bởi Adve và Gharachorloo, đây là sự lựa chọn phù hợp để bạn bắt đầu tìm hiểu sâu hơn nếu muốn tìm hiểu sâu hơn về các mô hình nhất quán trong bộ nhớ.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Rào cản bộ nhớ
Một bài viết nhỏ tóm tắt các vấn đề.
https://en.wikipedia.org/wiki/Memory_barrier
Kiến thức cơ bản về luồng
Video giới thiệu của Hans Boehm về lập trình đa luồng trong C++ và Java. Nội dung thảo luận về cuộc đua dữ liệu và các phương thức đồ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 nhiều chủ đề một cách chi tiết. Đây là chế độ nên dùng đối với 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 nội dung giải thích về việc đồng bộ hoá, các biến dễ biến động và cách xây dựng các trường cuối cùng. (Một chút 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
Nội dung 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. Các vấn đề này không áp dụng cho các chương trình không có đường đua 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 đề "Các thuộc tính nhất quán về bộ nhớ" nhằm giải thích những đảm bảo của nhiều lớp.
Thông tin 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 những mối nguy hiểm của việc tham chiếu thoát trong quá trình tạo đối tượng, đồng thời cung cấp 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 gì bạn có thể thực hiện 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
Nội dung khai báo "Khoá được kiểm tra hai lần bị hỏng"
Bill Pugh giải thích chi tiết về những cách khiến 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/DoubleCheckLocking.html
[ARM] Các xét nghiệm và hướng dẫn nấu ăn theo chuẩn rào cản
Nội dung thảo luận về các vấn đề liên quan đến ARM SMP, làm sáng tỏ 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á cụ thể hoặc muốn đọc phần mô tả chính thức về hướng dẫn DMB, hãy đọc thông tin này. Đồng thời mô tả các hướng dẫn dùng cho các rào cản bộ nhớ đối với mã có thể thực thi (có thể hữu ích nếu bạn đang tạo mã một cách nhanh chóng). Xin lưu ý rằng phiên bản này có trước ARMv8, cũng hỗ trợ các hướng dẫn bổ sung về thứ tự bộ nhớ 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 chiếu kiến trúc ARM® ARMv8, dành cho hồ sơ cấu trúc ARMv8-A" để biết 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ớ hạt nhân Linux
Tài liệu về các rào cản bộ nhớ của nhân hệ điều hành Linux. Bao gồm một số ví dụ hữu ích và hình minh hoạ ASCII.
http://www.kernel.org/doc/Tài liệu/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à điều 29 (“Thư viện toán tử nguyên tử”)
Bản nháp tiêu chuẩn cho các tính năng vận hành nguyên tử C++. Phiên bản này gần với tiêu chuẩn C++14, có những thay đổi nhỏ trong phần này so với C++11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(intro: http://www.hpl.hp.com/techreportingH/2008/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ử <stdatomic.h>”)
Tiêu chuẩn dự thảo cho các tính năng vận hành nguyên tử theo ISO/IEC 9899-201x C. Để biết thông tin chi tiết, hãy xem cả các báo cáo lỗi sau.
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)
Tập hợp các bản dịch nguyên tử C++ của Jaroslav Sev và Peter Sewell vào các 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 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 đủ, 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://vi.wikipedia.org/wiki/Dekker's_algorithm
Nhận xét về các phần phụ thuộc ARM so với Alpha và địa chỉ
Một email trong danh sách gửi thư riêng của Catalin Marinas. Bao gồm bản tóm tắt hay về các phần phụ thuộc điều khiển và địa chỉ.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html
Những điều mọi lập trình viên cầ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 của CPU, của Ulrich Drepper.
http://www.akkadia.org/drepper/cpumemory.pdf
Lý do về mô hình bộ nhớ ARM kém nhất quán
Bài viết này là tác giả của Chong & Ishtiaq thuộc ARM, Ltd. Bài viết này cố gắng mô tả mô hình bộ nhớ ARM SMP theo 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 trong bài viết này. Xin nhắc lại rằng yêu cầu này có trước phiên bản ARMv8.
http://cổng.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll= khoa=vi&CFID=96099715&CFTOKEN=57505711
Cẩm nang JSR-133 dành cho người viết trình biên dịch
Doug Lea viết chương trình này dưới dạng tài liệu đồng hành với tài liệu JSR-133 (Mô hình bộ nhớ Java). Tệp 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 và vẫn được trích dẫn rộng rãi cũng như có thể cung cấp thông tin chi tiết. Thật không may, bốn phiên bản hàng rào được thảo luận ở đây không phù hợp với các kiến trúc được Android hỗ trợ và các ánh xạ C++11 ở trên hiện là một nguồn tốt hơn để cung cấp các công thức nấu ăn chính xác, 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à có thể sử dụng cho đa bộ xử lý x86
Nội dung mô tả chính xác về mô hình bộ nhớ x86. Nội dung mô tả chính xác về mô hình bộ nhớ ARM thật sự phức tạp hơn nhiều.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf