Đa luồng không khóa dành cho các chuyên gia phân luồng thực sự


86

Tôi đang đọc một câu trả lờiJon Skeet đưa ra cho một câu hỏi và trong đó anh ấy đã đề cập đến điều này:

Theo như tôi được biết, đa luồng không có khóa dành cho các chuyên gia phân luồng thực sự, trong số đó tôi không phải là một.

Đây không phải là lần đầu tiên tôi nghe thấy điều này, nhưng tôi thấy rất ít người nói về cách bạn thực sự làm điều đó nếu bạn quan tâm đến việc học cách viết mã đa luồng không khóa.

Vì vậy, câu hỏi của tôi là ngoài việc học tất cả những gì bạn có thể về phân luồng, v.v., bạn sẽ bắt đầu cố gắng học cách viết mã đa luồng không có khóa ở đâu và một số tài nguyên tốt là gì.

Chúc mừng


Tôi sử dụng nền tảng gcc, linux và X86 / X68. Không có khóa gần như không khó vì tất cả chúng đều tạo ra âm thanh! Cấu tạo nguyên tử gcc có rào cản về bộ nhớ trên intel, nhưng điều đó không quan trọng trong cuộc sống thực. Điều quan trọng là bộ nhớ được sửa đổi nguyên tử. Nó chỉ xuất hiện khi bạn thiết kế cấu trúc dữ liệu "khóa miễn phí" mà không quan trọng khi một luồng khác thấy thay đổi. Danh sách liên kết đơn, danh sách bỏ qua, bảng băm, danh sách miễn phí, v.v. đều khá dễ thực hiện miễn phí. Khóa miễn phí không dành cho mọi thứ. Nó chỉ là một công cụ khác phù hợp cho một số tình huống nhất định.
johnnycrash


Bỏ phiếu để đóng dưới dạng đề xuất tài nguyên hoặc không rõ bạn đang hỏi gì.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Câu trả lời:


100

Các triển khai "không khóa" hiện tại hầu hết đều tuân theo cùng một mẫu:

  • * đọc một số tiểu bang và tạo một bản sao của nó **
  • * sửa đổi bản sao **
  • thực hiện một hoạt động đan xen
  • thử lại nếu nó không thành công

(* tùy chọn: phụ thuộc vào cấu trúc dữ liệu / thuật toán)

Bit cuối cùng tương tự như một spinlock. Trên thực tế, nó là một spinlock cơ bản . :)
Tôi đồng ý với @nobugz về điều này: chi phí của các hoạt động được lồng vào nhau được sử dụng trong đa luồng không khóa bị chi phối bởi các tác vụ bộ nhớ đệm và đồng bộ nhớ mà nó phải thực hiện .

Tuy nhiên, những gì bạn đạt được với cấu trúc dữ liệu "không có khóa" là "ổ khóa" của bạn rất chi tiết . Điều này làm giảm khả năng hai luồng đồng thời truy cập cùng một "khóa" (vị trí bộ nhớ).

Thủ thuật thường gặp là bạn không có các khóa chuyên dụng - thay vào đó bạn coi tất cả các phần tử trong một mảng hoặc tất cả các nút trong danh sách được liên kết như một "khóa quay". Bạn đọc, sửa đổi và cố gắng cập nhật nếu không có bản cập nhật nào kể từ lần đọc cuối cùng của bạn. Nếu có, bạn thử lại.
Điều này làm cho "khóa" của bạn (ồ, xin lỗi, không khóa :) rất chi tiết, mà không giới thiệu thêm bộ nhớ hoặc yêu cầu tài nguyên.
Làm cho nó trở nên chi tiết hơn sẽ làm giảm xác suất đợi. Làm cho nó càng chi tiết càng tốt mà không cần thêm yêu cầu tài nguyên nghe có vẻ tuyệt vời, phải không?

Tuy nhiên, hầu hết niềm vui có thể đến từ việc đảm bảo xếp hàng đúng tải / cửa hàng .
Trái ngược với trực giác của mọi người, CPU có thể tự do sắp xếp lại các lần đọc / ghi của bộ nhớ - nhân tiện, chúng rất thông minh: bạn sẽ khó quan sát điều này từ một luồng duy nhất. Tuy nhiên, bạn sẽ gặp phải sự cố khi bắt đầu thực hiện đa luồng trên nhiều lõi. Trực giác của bạn sẽ bị phá vỡ: chỉ vì một chỉ dẫn sớm hơn trong mã của bạn, điều đó không có nghĩa là nó thực sự sẽ xảy ra sớm hơn. CPU có thể xử lý các lệnh không theo thứ tự: và chúng đặc biệt thích làm điều này với các lệnh có quyền truy cập bộ nhớ, để ẩn độ trễ của bộ nhớ chính và sử dụng tốt hơn bộ nhớ cache của chúng.

Bây giờ, chắc chắn chống lại trực giác rằng một chuỗi mã không chảy "từ trên xuống", thay vào đó nó chạy như thể không có trình tự nào - và có thể được gọi là "sân chơi của ma quỷ". Tôi tin rằng không thể đưa ra câu trả lời chính xác về việc tải / lưu trữ lại quy trình sẽ diễn ra. Thay vào đó, người ta luôn nói về maysmightslon và chuẩn bị cho điều tồi tệ nhất. "Ồ, CPU có thể sắp xếp lại thứ tự đọc này trước khi ghi, vì vậy tốt nhất là đặt một rào cản bộ nhớ ngay tại đây, tại chỗ này."

Những vấn đề khá phức tạp bởi thực tế là ngay cả những maysmights có thể khác nhau giữa các kiến trúc CPU. Chẳng hạn, có thể xảy ra trường hợp điều gì đó được đảm bảo không xảy ra trong một kiến ​​trúc có thể xảy ra trên một kiến ​​trúc khác.


Để có được quyền đa luồng "không có khóa", bạn phải hiểu các mô hình bộ nhớ.
Tuy nhiên, việc bắt được mô hình bộ nhớ và các đảm bảo chính xác không phải là chuyện nhỏ, như đã được minh chứng trong câu chuyện này, theo đó Intel và AMD đã thực hiện một số chỉnh sửa đối với tài liệu MFENCEgây ra một số xôn xao trong giới phát triển JVM . Hóa ra, tài liệu mà các nhà phát triển dựa vào ngay từ đầu đã không chính xác như vậy.

Các ổ khóa trong .NET dẫn đến một rào cản bộ nhớ ngầm, vì vậy bạn có thể an toàn khi sử dụng chúng (hầu hết thời gian, nghĩa là ... hãy xem ví dụ này Joe Duffy - Brad Abrams - Vance Morrison sự tuyệt vời về khởi tạo lười biếng, khóa, chất bay hơi và bộ nhớ rào cản. :) (Hãy chắc chắn theo các liên kết trên trang đó.)

Như một phần thưởng bổ sung, bạn sẽ được giới thiệu với mô hình bộ nhớ .NET trong một nhiệm vụ phụ . :)

Ngoài ra còn có một "oldie but goldie" từ Vance Morrison: Những gì mọi nhà phát triển phải biết về ứng dụng đa luồng .

... và tất nhiên, như @Eric đã đề cập, Joe Duffy là một người đọc rõ ràng về chủ đề này.

Một STM tốt có thể đạt được gần đến mức khóa chi tiết và có thể sẽ cung cấp hiệu suất gần bằng hoặc ngang bằng với việc triển khai thủ công. Một trong số đó là STM.NET từ các dự án DevLabs của MS.

Nếu bạn không phải là người đam mê .NET, Doug Lea đã thực hiện một số công việc tuyệt vời trong JSR-166 .
Cliff Click có một điểm thú vị đối với các bảng băm không dựa vào dải khóa - như các bảng băm đồng thời của Java và .NET - và dường như có thể mở rộng quy mô thành 750 CPU.

Nếu bạn không ngại dấn thân vào lãnh thổ Linux, bài viết dưới đây sẽ cung cấp thêm thông tin chi tiết về nội hàm của kiến ​​trúc bộ nhớ hiện tại và cách chia sẻ dòng bộ nhớ đệm có thể phá hủy hiệu suất: Điều mà mọi lập trình viên nên biết về bộ nhớ .

@Ben đưa ra nhiều nhận xét về Bộ KH & ĐT: Tôi chân thành đồng ý rằng Bộ KH & ĐT có thể tỏa sáng trong một số lĩnh vực. Một giải pháp dựa trên MPI có thể dễ lập luận hơn, dễ thực hiện hơn và ít bị lỗi hơn so với việc triển khai khóa nửa chừng cố gắng trở nên thông minh. (Tuy nhiên - về mặt chủ quan - cũng đúng đối với giải pháp dựa trên STM.) Tôi cũng dám cá rằng việc viết chính xác một ứng dụng phân tán tốt trong ví dụ Erlang sẽ dễ dàng hơn nhiều năm như nhiều ví dụ thành công cho thấy.

Tuy nhiên, MPI có những chi phí riêng và những rắc rối riêng khi nó được chạy trên một hệ thống đa lõi, đơn . Ví dụ: ở Erlang, có những vấn đề cần giải quyết xung quanh việc đồng bộ hóa lịch trình quy trình và hàng đợi tin nhắn .
Ngoài ra, về cốt lõi, các hệ thống MPI thường triển khai loại lập lịch N: M hợp tác cho "các quy trình nhẹ". Ví dụ, điều này có nghĩa là không thể tránh khỏi sự chuyển đổi ngữ cảnh giữa các quy trình nhẹ. Đúng là nó không phải là một "công tắc ngữ cảnh cổ điển" mà chủ yếu là một hoạt động không gian của người dùng và nó có thể được thực hiện nhanh chóng - tuy nhiên tôi thực sự nghi ngờ rằng nó có thể được thực hiện theo chu kỳ 20-200 một hoạt động được khóa liên tục . Chuyển đổi ngữ cảnh chế độ người dùng chắc chắn chậm hơnngay cả trong thư viện Intel McRT. Việc lập lịch trình N: M không phải là mới. Các LWP đã có ở Solaris trong một thời gian dài. Họ đã bị bỏ rơi. Đã có sợi trong NT. Bây giờ chúng hầu hết là một di tích. Đã có "kích hoạt" trong NetBSD. Họ đã bị bỏ rơi. Linux có chủ đề riêng về luồng N: M. Bây giờ nó dường như đã chết.
Đôi khi, có những đối thủ mới: ví dụ như McRT của Intel , hoặc gần đây nhất là Lập lịch chế độ người dùng cùng với ConCRT của Microsoft.
Ở cấp thấp nhất, họ làm những gì một bộ lập lịch N: M MPI làm. Erlang - hoặc bất kỳ hệ thống MPI nào -, có thể được hưởng lợi rất nhiều trên các hệ thống SMP bằng cách khai thác UMS mới .

Tôi đoán câu hỏi của OP không phải về giá trị của và lập luận chủ quan cho / chống lại bất kỳ giải pháp nào, nhưng nếu tôi phải trả lời câu hỏi đó, tôi đoán nó phụ thuộc vào nhiệm vụ: để xây dựng cấu trúc dữ liệu cơ bản cấp thấp, hiệu suất cao chạy trên hệ thống đơn với nhiều lõi , kỹ thuật khóa thấp / "không khóa" hoặc một STM sẽ mang lại kết quả tốt nhất về hiệu suất và có thể sẽ đánh bại giải pháp MPI bất kỳ lúc nào về hiệu suất, ngay cả khi các nếp nhăn trên đã được ủi phẳng ví dụ như ở Erlang.
Đối với việc xây dựng bất cứ thứ gì phức tạp hơn vừa phải chạy trên một hệ thống, có lẽ tôi sẽ chọn khóa hạt thô cổ điển hoặc nếu hiệu suất là vấn đề đáng quan tâm, thì một STM.
Để xây dựng một hệ thống phân tán, một hệ thống MPI có thể sẽ là một lựa chọn tự nhiên.
Lưu ý rằng cũng có các triển khai MPI cho .NET (mặc dù chúng dường như không hoạt động).


1
Mặc dù câu trả lời này có rất nhiều thông tin tốt, nhưng ý tưởng tiêu đề cho rằng các thuật toán và cấu trúc dữ liệu không có khóa về cơ bản chỉ là một tập hợp các spinlock chi tiết rất sai lầm. Mặc dù bạn thường thấy các vòng lặp thử lại trong các cấu trúc không có khóa, nhưng hành vi rất khác: các khóa (bao gồm cả spinlock) độc quyền thu được một số tài nguyên và các luồng khác không thể tiến triển trong khi nó bị giữ. "Thử lại" theo nghĩa đó chỉ đơn giản là chờ tài nguyên độc quyền được phát hành.
BeeOnRope

1
Mặt khác, các thuật toán không khóa không sử dụng CAS hoặc các lệnh nguyên tử khác để có được tài nguyên độc quyền, mà là để hoàn thành một số hoạt động. Nếu chúng không thành công, đó là do một cuộc đua chi tiết tạm thời với một luồng khác và trong trường hợp đó , luồng khác đã đạt được tiến bộ (đã hoàn thành hoạt động của nó). Nếu một luồng bị nghi ngờ vô thời hạn, tất cả các luồng khác vẫn có thể tiến triển. Điều này là cả về chất lượng và hiệu suất khôn ngoan rất khác với các khóa độc quyền. Số lượng "thử lại" thường là rất thấp đối với hầu hết CAS-vòng ngay cả dưới tranh nặng nề ...
BeeOnRope

1
... nhưng điều đó tất nhiên không có nghĩa là mở rộng quy mô tốt: tranh giành một vị trí bộ nhớ duy nhất sẽ luôn diễn ra khá chậm trên các máy SMP, chỉ do độ trễ giữa các ổ cắm liên lõi, ngay cả khi số lỗi CAS là Thấp.
BeeOnRope

1
@AndrasVass - Tôi đoán nó cũng phụ thuộc vào mã không khóa "tốt" và "xấu". Chắc chắn ai cũng có thể viết một cấu trúc và gọi nó là không có khóa trong khi nó thực sự chỉ sử dụng spinlock ở chế độ người dùng và thậm chí không đáp ứng định nghĩa. Tôi cũng khuyến khích bất kỳ độc giả nào quan tâm xem bài báo này của Herlihy và Shavit, bài báo này xem xét một cách chính thức về các danh mục khác nhau của các thuật toán có khóa và không có khóa. Bất cứ điều gì của Herlihy về chủ đề này cũng được khuyến khích đọc.
BeeOnRope

1
@AndrasVass - Tôi không đồng ý. Hầu hết các cấu trúc không có khóa cổ điển (danh sách, hàng đợi, bản đồ đồng thời, v.v.) không quay vòng ngay cả đối với các cấu trúc có thể thay đổi được chia sẻ và các triển khai thực tế hiện có giống nhau, chẳng hạn như Java tuân theo cùng một mẫu (tôi không quen thuộc với những gì có sẵn trong C hoặc C ++ được biên dịch gốc và khó hơn ở đó do không có bộ sưu tập rác). Có lẽ bạn và tôi có một định nghĩa khác về quay: Tôi không coi cách "thử lại CAS" mà bạn tìm thấy trong những thứ không có khóa là "quay". IMO "quay" ngụ ý chờ đợi nóng.
BeeOnRope

27

Sách của Joe Duffy:

http://www.bluebytesoftware.com/books/winconc/winconc_book_resources.html

Anh ấy cũng viết blog về những chủ đề này.

Mẹo để cài đặt đúng các chương trình khóa thấp là hiểu chính xác các quy tắc của mô hình bộ nhớ trên tổ hợp phần cứng, hệ điều hành và môi trường thời gian chạy cụ thể của bạn.

Cá nhân tôi không ở đâu đủ thông minh để thực hiện chính xác lập trình khóa thấp ngoài InterlockedIncrement, nhưng nếu bạn có, tuyệt vời, hãy tiếp tục. Chỉ cần đảm bảo rằng bạn để lại nhiều tài liệu trong mã để những người không thông minh như bạn không vô tình phá vỡ một trong những bất biến mô hình bộ nhớ của bạn và đưa ra một lỗi không thể tìm thấy.


38
Vì vậy, nếu cả Eric LippertJon Skeet đều nghĩ rằng lập trình miễn phí khóa chỉ dành cho những người thông minh hơn mình, thì tôi sẽ khiêm tốn bỏ chạy khỏi ý tưởng đó ngay lập tức. ;-)
dodgy_coder

20

Không có cái gọi là "luồng không khóa" những ngày này. Đó là một sân chơi thú vị cho giới học thuật và những thứ tương tự, vào cuối thế kỷ trước khi phần cứng máy tính chậm và đắt tiền. Thuật toán của Dekker luôn là thuật toán yêu thích của tôi, phần cứng hiện đại đã đưa nó ra đồng cỏ. Nó không hoạt động nữa.

Hai sự phát triển đã kết thúc điều này: sự chênh lệch ngày càng tăng giữa tốc độ của RAM và CPU. Và khả năng các nhà sản xuất chip đưa nhiều hơn một lõi CPU vào một con chip.

Vấn đề tốc độ RAM đòi hỏi các nhà thiết kế chip phải đặt một bộ đệm trên chip CPU. Bộ đệm lưu trữ mã và dữ liệu, có thể truy cập nhanh chóng bởi lõi CPU. Và có thể được đọc và ghi từ / vào RAM với tốc độ chậm hơn nhiều. Bộ đệm này được gọi là bộ đệm CPU, hầu hết các CPU đều có ít nhất hai bộ đệm trong số đó. Bộ nhớ cache cấp 1 nhỏ và nhanh, cấp 2 lớn và chậm hơn. Miễn là CPU có thể đọc dữ liệu và hướng dẫn từ bộ nhớ cache cấp 1, nó sẽ chạy nhanh. Việc bỏ lỡ bộ nhớ đệm thực sự rất tốn kém, nó đặt CPU ở trạng thái ngủ trong 10 chu kỳ nếu dữ liệu không có trong bộ đệm thứ nhất, lên đến 200 chu kỳ nếu nó không có trong bộ đệm thứ 2 và nó cần được đọc từ RAM.

Mỗi lõi CPU đều có bộ nhớ đệm riêng, chúng lưu trữ "view" RAM của riêng mình. Khi CPU ghi dữ liệu, quá trình ghi được thực hiện vào bộ nhớ cache, sau đó, từ từ, được chuyển vào RAM. Không thể thay đổi, mỗi lõi bây giờ sẽ có một cái nhìn khác nhau về nội dung RAM. Nói cách khác, một CPU không biết CPU khác đã viết gì cho đến khi chu kỳ ghi RAM đó hoàn thành CPU làm mới chế độ xem của chính nó.

Điều đó không tương thích đáng kể với luồng. Bạn luôn thực sự quan tâm đến trạng thái của một luồng khác là gì khi bạn phải đọc dữ liệu được ghi bởi một luồng khác. Để đảm bảo điều này, bạn cần lập trình rõ ràng cái gọi là rào cản bộ nhớ. Nó là một CPU nguyên thủy cấp thấp đảm bảo rằng tất cả các bộ nhớ đệm của CPU đều ở trạng thái nhất quán và có chế độ xem RAM cập nhật. Tất cả các ghi đang chờ xử lý phải được chuyển vào RAM, các bộ nhớ đệm sau đó cần được làm mới.

Điều này có sẵn trong .NET, phương thức Thread.MemoryBarrier () thực hiện một phương thức. Cho rằng đây là 90% công việc mà câu lệnh khóa thực hiện (và 95 +% thời gian thực thi), bạn chỉ đơn giản là không dẫn trước bằng cách tránh các công cụ mà .NET cung cấp cho bạn và cố gắng thực hiện của riêng bạn.


2
@ Davy8: thành phần làm vẫn cứng. Nếu tôi có hai bảng băm không có khóa và với tư cách là người tiêu dùng, tôi truy cập cả hai bảng đó, điều này sẽ không đảm bảo tính nhất quán của trạng thái nói chung. Gần nhất bạn có thể đến ngày hôm nay là STM, nơi bạn có thể đặt hai truy cập, ví dụ: trong một atomickhối duy nhất . Nói chung, việc sử dụng các cấu trúc không có khóa có thể phức tạp như vậy trong nhiều trường hợp.
Andras Vass

4
Tôi có thể sai, nhưng tôi nghĩ bạn đã giải thích sai cách hoạt động của đồng tiền bộ nhớ cache. Hầu hết các bộ xử lý đa lõi hiện đại đều có bộ nhớ đệm nhất quán, có nghĩa là phần cứng bộ đệm xử lý đảm bảo rằng tất cả các quy trình đều có cùng chế độ xem nội dung RAM - bằng cách chặn các lệnh gọi "đọc" cho đến khi tất cả các lệnh gọi "ghi" tương ứng hoàn tất. Tài liệu Thread.MemoryBarrier () ( msdn.microsoft.com/en-us/library/… ) không nói gì về hành vi của bộ nhớ cache - nó chỉ đơn giản là một lệnh ngăn bộ xử lý sắp xếp lại các lần đọc và ghi.
Brooks Moses

7
"Không có cái gọi là" luồng không khóa "những ngày này." Hãy nói điều đó với các lập trình viên Erlang và Haskell.
Juliet

4
@HansPassant: "Không có cái gọi là 'luồng không khóa' những ngày này". F #, Erlang, Haskell, Cilk, OCaml, Thư viện song song tác vụ (TPL) của Microsoft và Khối xây dựng có luồng (TBB) của Intel đều khuyến khích lập trình đa luồng không khóa. Tôi hiếm khi sử dụng khóa trong mã sản xuất những ngày này.
JD

5
@HansPassant: "cái gọi là rào cản bộ nhớ. Nó là một CPU nguyên thủy cấp thấp đảm bảo rằng tất cả các bộ nhớ đệm của CPU đều ở trạng thái nhất quán và có chế độ xem cập nhật về RAM. Tất cả các ghi đang chờ xử lý phải được chuyển vào RAM, bộ nhớ đệm sau đó cần được làm mới ". Rào cản bộ nhớ trong ngữ cảnh này ngăn không cho các lệnh bộ nhớ (tải và lưu trữ) được sắp xếp lại bởi trình biên dịch hoặc CPU. Không liên quan gì đến tính nhất quán của bộ nhớ đệm CPU.
JD


0

Khi nói đến đa luồng, bạn phải biết chính xác những gì bạn đang làm. Ý tôi là khám phá tất cả các tình huống / trường hợp có thể xảy ra khi bạn đang làm việc trong môi trường đa luồng. Đa luồng không có khóa không phải là một thư viện hay một lớp học mà chúng tôi kết hợp, nó là một kiến ​​thức / kinh nghiệm mà chúng tôi kiếm được trong hành trình của mình trên các chuỗi.


Có rất nhiều thư viện cung cấp ngữ nghĩa luồng không khóa. STM được quan tâm đặc biệt, trong đó có khá nhiều triển khai xung quanh.
Marcelo Cantos

Tôi thấy cả hai mặt của điều này. Để có được hiệu suất hiệu quả từ thư viện không có khóa đòi hỏi kiến ​​thức sâu sắc về các mô hình bộ nhớ. Nhưng một lập trình viên không có kiến ​​thức đó vẫn có thể hưởng lợi từ những lợi thế về tính đúng đắn.
Ben Voigt

0

Mặc dù việc phân luồng không khóa có thể khó khăn trong .NET, thường thì bạn có thể cải thiện đáng kể khi sử dụng khóa bằng cách nghiên cứu chính xác những gì cần khóa và giảm thiểu phần bị khóa ... điều này còn được gọi là giảm thiểu mức độ chi tiết của khóa .

Ví dụ, chỉ cần nói rằng bạn cần tạo một chuỗi thu thập an toàn. Đừng chỉ ném khóa một cách mù quáng đối với một phương thức lặp lại trên bộ sưu tập nếu nó thực hiện một số tác vụ đòi hỏi nhiều CPU trên mỗi mục. Bạn có thể chỉ cần đặt một khóa xung quanh việc tạo ra một bản sao nông của bộ sưu tập. Lặp lại bản sao sau đó có thể hoạt động mà không cần khóa. Tất nhiên điều này phụ thuộc nhiều vào các chi tiết cụ thể của mã của bạn, nhưng tôi đã có thể khắc phục sự cố đoàn xe bị khóa bằng cách tiếp cận này.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.