Bạn có thể giải thích tại sao nhiều luồng cần khóa trên CPU lõi đơn không?


18

Giả sử các luồng này chạy trong cpu lõi đơn. Là một cpu chỉ chạy một lệnh trong một chu kỳ. Điều đó nói rằng, thậm chí nghĩ rằng họ chia sẻ tài nguyên cpu. nhưng máy tính đảm bảo rằng một lần một hướng dẫn. Vì vậy, khóa là không cần thiết cho nhiều trang?


Bởi vì bộ nhớ giao dịch phần mềm chưa phải là chủ đạo.
dan_waterworth

@dan_waterworth Vì bộ nhớ giao dịch phần mềm thất bại nặng nề ở mức độ phức tạp không tầm thường, ý bạn là gì? ;)
Mason Wheeler

Tôi cá là Rich Hickey không đồng ý với điều đó.
Robert Harvey

@MasonWheeler, trong khi khóa không tầm thường hoạt động tốt đáng kinh ngạc và chưa bao giờ là một nguồn lỗi tinh vi khó theo dõi? STM hoạt động tốt với các mức độ phức tạp không tầm thường, nhưng nó có vấn đề khi có sự tranh chấp. Trong những trường hợp đó, một cái gì đó như thế này , một dạng STM hạn chế hơn sẽ tốt hơn. Btw, với sự thay đổi tiêu đề, tôi phải mất một thời gian để tìm ra lý do tại sao tôi nhận xét như tôi đã làm.
dan_waterworth

Câu trả lời:


32

Điều này được minh họa tốt nhất với một ví dụ.

Giả sử chúng ta có một nhiệm vụ đơn giản là chúng ta muốn thực hiện song song nhiều lần và chúng ta muốn theo dõi trên toàn cầu số lần thực hiện nhiệm vụ đó, ví dụ, đếm số lần truy cập trên một trang web.

Khi mỗi luồng đến điểm tăng số đếm, việc thực thi của nó sẽ giống như sau:

  1. Đọc số lần truy cập từ bộ nhớ vào thanh ghi bộ xử lý
  2. Tăng số đó.
  3. Viết số đó trở lại bộ nhớ

Hãy nhớ rằng mọi luồng có thể tạm dừng tại bất kỳ điểm nào trong quy trình này. Vì vậy, nếu luồng A thực hiện bước 1, và sau đó bị đình chỉ, tiếp theo luồng B thực hiện cả ba bước, khi luồng A tiếp tục, các thanh ghi của nó sẽ có số lần truy cập sai: các thanh ghi của nó sẽ được khôi phục, nó sẽ vui vẻ tăng số cũ số lần truy cập và lưu trữ số tăng dần đó.

Ngoài ra, bất kỳ số lượng các luồng khác có thể đã chạy trong thời gian luồng A bị treo, do đó, chuỗi đếm A ghi ở cuối có thể thấp hơn số đếm chính xác.

Vì lý do đó, cần phải đảm bảo rằng nếu một luồng thực hiện bước 1, thì nó phải thực hiện bước 3 trước khi bất kỳ luồng nào khác được phép thực hiện bước 1, có thể được thực hiện bởi tất cả các luồng đang chờ để có một khóa duy nhất trước khi chúng bắt đầu quá trình này và chỉ giải phóng khóa sau khi quá trình hoàn tất, do đó "phần quan trọng" của mã này không thể được xen kẽ không chính xác, dẫn đến đếm sai.

Nhưng nếu hoạt động là nguyên tử thì sao?

Vâng, trong vùng đất của kỳ lân và cầu vồng ma thuật, nơi hoạt động gia tăng là nguyên tử, sau đó khóa sẽ không cần thiết cho ví dụ trên.

Tuy nhiên, điều quan trọng cần nhận ra là chúng ta dành rất ít thời gian trong thế giới kỳ lân và cầu vồng. Trong hầu hết mọi ngôn ngữ lập trình, thao tác tăng được chia thành ba bước trên. Đó là bởi vì, ngay cả khi bộ xử lý hỗ trợ hoạt động gia tăng nguyên tử, thì hoạt động đó đắt hơn đáng kể: nó phải đọc từ bộ nhớ, sửa đổi số và ghi lại vào bộ nhớ ... và thông thường hoạt động gia tăng nguyên tử là một hoạt động có thể thất bại, có nghĩa là chuỗi đơn giản ở trên phải được thay thế bằng một vòng lặp (như chúng ta sẽ thấy bên dưới).

Vì, ngay cả trong mã đa luồng, nhiều biến được giữ cục bộ cho một luồng, các chương trình sẽ hiệu quả hơn nhiều nếu chúng giả sử mỗi biến là cục bộ của một luồng và để các lập trình viên chăm sóc bảo vệ trạng thái chia sẻ giữa các luồng. Đặc biệt là các hoạt động nguyên tử thường không đủ để giải quyết các vấn đề luồng, như chúng ta sẽ thấy sau.

Biến dễ bay hơi

Nếu chúng ta muốn tránh các khóa cho vấn đề cụ thể này, trước tiên chúng ta phải nhận ra rằng các bước được mô tả trong ví dụ đầu tiên của chúng ta không thực sự là những gì xảy ra trong mã được biên dịch hiện đại. Bởi vì trình biên dịch giả định chỉ có một luồng đang sửa đổi biến, mỗi luồng sẽ giữ bản sao lưu của biến được lưu trong bộ nhớ cache của chính nó, cho đến khi thanh ghi bộ xử lý là cần thiết cho thứ khác. Miễn là nó có bản sao được lưu trong bộ nhớ cache, nó giả định rằng nó không cần quay lại bộ nhớ và đọc lại (sẽ rất tốn kém). Họ cũng sẽ không ghi lại biến vào bộ nhớ miễn là nó được giữ trong một thanh ghi.

Chúng ta có thể quay lại tình huống mà chúng ta đã đưa ra trong ví dụ đầu tiên (với tất cả các vấn đề luồng tương tự mà chúng ta đã xác định ở trên) bằng cách đánh dấu biến là biến động , thông báo cho trình biên dịch rằng biến này đang được sửa đổi bởi người khác và do đó phải được đọc từ hoặc ghi vào bộ nhớ bất cứ khi nào nó được truy cập hoặc sửa đổi.

Vì vậy, một biến được đánh dấu là không ổn định sẽ không đưa chúng ta đến vùng đất của các hoạt động gia tăng nguyên tử, nó chỉ khiến chúng ta gần gũi như chúng ta nghĩ chúng ta đã có.

Làm nguyên tử tăng dần

Khi chúng tôi đang sử dụng biến dễ bay hơi, chúng tôi có thể biến nguyên tử hoạt động gia tăng của mình bằng cách sử dụng thao tác tập có điều kiện cấp thấp mà hầu hết các CPU hiện đại hỗ trợ (thường được gọi là so sánh và đặt hoặc so sánh và trao đổi ). Cách tiếp cận này được thực hiện, ví dụ, trong lớp AtomicInteger của Java :

197       /**
198        * Atomically increments by one the current value.
199        *
200        * @return the updated value
201        */
202       public final int incrementAndGet() {
203           for (;;) {
204               int current = get();
205               int next = current + 1;
206               if (compareAndSet(current, next))
207                   return next;
208           }
209       }

Vòng lặp trên liên tục thực hiện các bước sau, cho đến khi bước 3 thành công:

  1. Đọc giá trị của một biến động trực tiếp từ bộ nhớ.
  2. Tăng giá trị đó.
  3. Thay đổi giá trị (trong bộ nhớ chính) khi và chỉ khi giá trị hiện tại của nó trong bộ nhớ chính giống với giá trị ban đầu chúng ta đọc, sử dụng thao tác nguyên tử đặc biệt.

Nếu bước 3 không thành công (vì giá trị đã bị thay đổi bởi một luồng khác sau bước 1), nó lại đọc biến trực tiếp từ bộ nhớ chính và thử lại.

Mặc dù thao tác so sánh và trao đổi rất tốn kém, nhưng nó tốt hơn một chút so với sử dụng khóa trong trường hợp này, bởi vì nếu một luồng bị treo sau bước 1, các luồng khác đạt đến bước 1 không phải chặn và chờ đợi luồng đầu tiên, mà có thể ngăn chặn chuyển đổi bối cảnh tốn kém. Khi luồng đầu tiên tiếp tục, nó sẽ thất bại trong lần thử đầu tiên để viết biến, nhưng sẽ có thể tiếp tục bằng cách đọc lại biến, điều này một lần nữa có thể ít tốn kém hơn so với chuyển đổi ngữ cảnh cần thiết với khóa.

Vì vậy, chúng ta có thể đến vùng đất tăng nguyên tử (hoặc các hoạt động khác trên một biến) mà không cần sử dụng khóa thực tế, thông qua so sánh và trao đổi.

Vậy khi nào thì khóa là cần thiết?

Nếu bạn cần sửa đổi nhiều hơn một biến trong hoạt động nguyên tử, thì việc khóa sẽ là cần thiết, bạn sẽ không tìm thấy hướng dẫn bộ xử lý đặc biệt cho điều đó.

Miễn là bạn đang làm việc với một biến duy nhất và bạn đã chuẩn bị cho bất kỳ công việc nào bạn đã làm thất bại và phải đọc biến đó và bắt đầu lại, tuy nhiên, so sánh và trao đổi sẽ đủ tốt.

Chúng ta hãy xem xét một ví dụ trong đó mỗi luồng đầu tiên thêm 2 vào một biến X và sau đó nhân X với hai.

Nếu X ban đầu là một và hai luồng chạy, chúng tôi hy vọng kết quả sẽ là (((1 + 2) * 2) + 2) * 2 = 16.

Tuy nhiên, nếu các luồng xen kẽ, chúng ta có thể, ngay cả với tất cả các hoạt động là nguyên tử, thay vào đó có cả hai phép cộng xảy ra trước và phép nhân xuất hiện sau, dẫn đến (1 + 2 + 2) * 2 * 2 = 20.

Điều này xảy ra bởi vì phép nhân và phép cộng không phải là phép toán giao hoán.

Vì vậy, các hoạt động tự nó là nguyên tử là không đủ, chúng ta phải thực hiện kết hợp các hoạt động nguyên tử.

Chúng ta có thể làm điều đó bằng cách sử dụng khóa để tuần tự hóa quá trình hoặc chúng ta có thể sử dụng một biến cục bộ để lưu trữ giá trị của X khi chúng ta bắt đầu tính toán, một biến cục bộ thứ hai cho các bước trung gian, sau đó sử dụng so sánh và trao đổi để chỉ đặt giá trị mới nếu giá trị hiện tại của X giống với giá trị ban đầu của X. Nếu thất bại, chúng ta sẽ phải bắt đầu lại bằng cách đọc X và thực hiện lại các phép tính.

Có một số sự đánh đổi liên quan: khi các tính toán trở nên dài hơn, nhiều khả năng là luồng chạy sẽ bị treo và giá trị sẽ được sửa đổi bởi một luồng khác trước khi chúng tôi tiếp tục, có nghĩa là thất bại nhiều khả năng, dẫn đến lãng phí thời gian xử lý. Trong trường hợp cực đoan của số lượng lớn các luồng có tính toán chạy rất dài, chúng tôi có thể có 100 luồng đọc biến và được tham gia vào các phép tính, trong trường hợp đó chỉ có người đầu tiên hoàn thành sẽ viết thành giá trị mới, 99 luồng còn lại sẽ vẫn hoàn thành các tính toán của họ, nhưng khám phá khi hoàn thành rằng họ không thể cập nhật giá trị ... tại thời điểm đó, mỗi người sẽ đọc giá trị và bắt đầu tính toán. Chúng tôi có thể có 99 luồng còn lại lặp lại cùng một vấn đề, gây lãng phí thời gian xử lý lớn.

Tuần tự hóa toàn bộ phần quan trọng thông qua các khóa sẽ tốt hơn nhiều trong tình huống đó: 99 luồng sẽ tạm dừng khi họ không nhận được khóa và chúng tôi sẽ chạy từng luồng theo thứ tự đến điểm khóa.

Nếu việc tuần tự hóa không quan trọng (như trong trường hợp tăng dần của chúng tôi) và các tính toán sẽ bị mất nếu cập nhật số không thành công là tối thiểu, có thể có một lợi thế đáng kể để sử dụng thao tác so sánh và trao đổi, bởi vì hoạt động đó ít tốn kém hơn khóa.


Nhưng nếu đối trọng là nguyên tử, khóa có cần thiết không?
pythonee

@pythonee: nếu số lượt truy cập là nguyên tử, thì có thể không. Nhưng trong bất kỳ chương trình đa luồng nào có kích thước hợp lý, bạn sẽ có các nhiệm vụ phi nguyên tử được thực hiện trên một tài nguyên được chia sẻ.
Doc Brown

1
Trừ khi bạn đang sử dụng một trình biên dịch nội tại để tạo ra nguyên tử gia tăng, thì có lẽ không.
Mike Larsen

Có, nếu đọc / sửa đổi (tăng) / ghi là nguyên tử, khóa là không cần thiết, cho hoạt động đó. Lệnh DEC-10 AOSE (thêm một và bỏ qua nếu result == 0) được tạo thành nguyên tử một cách cụ thể để nó có thể được sử dụng như một semaphore thử nghiệm và thiết lập. Hướng dẫn đề cập rằng nó đủ tốt bởi vì nó sẽ khiến máy mất vài ngày liên tục để đếm một thanh ghi 36 bit trong suốt quá trình. Tuy nhiên, NGAY BÂY GIỜ, không phải mọi thứ bạn làm sẽ là "thêm một vào bộ nhớ".
John R. Strohm

Tôi đã cập nhật câu trả lời của mình để giải quyết một số vấn đề sau: có, bạn có thể tạo ra hoạt động nguyên tử, nhưng không, ngay cả trên các kiến ​​trúc hỗ trợ nó, nó sẽ không phải là nguyên tử theo mặc định, và có những tình huống nguyên tử không đủ và tuần tự hóa đầy đủ là cần thiết. Khóa là cơ chế duy nhất tôi biết để đạt được tuần tự hóa đầy đủ.
Theodore Murdock

4

Hãy xem xét trích dẫn này:

Một số người, khi đối mặt với một vấn đề, nghĩ: "Tôi biết, tôi sẽ sử dụng các chủ đề", và sau đó hai người họ lôi kéo poblesms

bạn thấy, ngay cả khi 1 lệnh chạy trên CPU tại bất kỳ thời điểm nào, các chương trình máy tính bao gồm nhiều hơn chỉ là các hướng dẫn lắp ráp nguyên tử. Vì vậy, ví dụ, ghi vào bàn điều khiển (hoặc một tệp) có nghĩa là bạn phải khóa để đảm bảo nó hoạt động như bạn muốn.


Tôi nghĩ rằng trích dẫn là biểu thức thông thường, không phải chủ đề?
dùng16764

3
Các trích dẫn có vẻ áp dụng nhiều hơn cho các chủ đề đối với tôi (với các từ / ký tự được in không theo thứ tự do các vấn đề luồng). Nhưng hiện tại có thêm một "s" trong đầu ra, điều này cho thấy mã có ba vấn đề.
Theodore Murdock

1
nó là một tác dụng phụ. Thỉnh thoảng bạn có thể thêm 1 cộng 1 và nhận 4294967295 :)
gbjbaanb

3

Có vẻ nhiều câu trả lời đã cố gắng giải thích về việc khóa, nhưng tôi nghĩ những gì OP cần là một lời giải thích về việc đa nhiệm thực sự là gì.

Khi bạn có nhiều luồng chạy trên một hệ thống ngay cả với một CPU, có hai phương pháp chính quyết định cách thức các luồng này sẽ được lên lịch (nghĩa là được đặt để chạy vào CPU lõi đơn của bạn):

  • Đa nhiệm hợp tác - Được sử dụng trong Win9x yêu cầu mỗi ứng dụng từ bỏ rõ ràng quyền kiểm soát. Trong trường hợp này, bạn sẽ không cần phải lo lắng về việc khóa miễn là Chủ đề A đang thực thi một số thuật toán, bạn sẽ được đảm bảo rằng nó sẽ không bao giờ bị gián đoạn
  • Đa nhiệm ưu tiên - Được sử dụng trong hầu hết các hệ điều hành hiện đại (Win2k trở lên). Điều này sử dụng thời gian và sẽ làm gián đoạn các chủ đề ngay cả khi chúng vẫn đang làm việc. Điều này mạnh mẽ hơn nhiều vì một luồng duy nhất không bao giờ có thể treo toàn bộ máy của bạn, đó là một khả năng thực sự với đa nhiệm hợp tác. Mặt khác, bây giờ bạn cần phải lo lắng về các khóa vì tại bất kỳ thời điểm nào, một trong các luồng của bạn có thể bị gián đoạn (tức là được ưu tiên) và HĐH có thể lên lịch cho một luồng khác để chạy. Khi mã hóa các ứng dụng đa luồng với hành vi này, bạn PHẢI xem xét rằng giữa mỗi dòng mã (hoặc thậm chí mỗi lệnh), một luồng khác nhau có thể chạy. Bây giờ, ngay cả với một lõi đơn, việc khóa trở nên rất quan trọng để đảm bảo trạng thái nhất quán của dữ liệu của bạn.

0

Vấn đề không nằm ở các hoạt động riêng lẻ, mà là các nhiệm vụ lớn hơn mà các hoạt động thực hiện.

Nhiều thuật toán được viết với giả định rằng chúng có toàn quyền kiểm soát trạng thái mà chúng hoạt động. Với mô hình thực thi theo thứ tự xen kẽ như mô hình bạn mô tả, các hoạt động có thể được xen kẽ tùy ý với nhau và nếu chúng chia sẻ trạng thái, có nguy cơ trạng thái ở dạng không nhất quán.

Bạn có thể so sánh nó với các hàm có thể tạm thời phá vỡ một bất biến để làm những gì chúng làm. Miễn là nhà nước trung gian không thể quan sát được từ bên ngoài, họ có thể làm bất cứ điều gì họ muốn để đạt được nhiệm vụ của mình.

Khi bạn viết mã đồng thời, bạn cần đảm bảo rằng trạng thái tranh chấp được coi là không an toàn trừ khi bạn có quyền truy cập độc quyền vào nó. Cách phổ biến để đạt được quyền truy cập độc quyền là đồng bộ hóa trên nguyên thủy đồng bộ hóa, như giữ một khóa.

Một điều nữa mà các nguyên thủy đồng bộ hóa có xu hướng dẫn đến trên một số nền tảng là chúng phát ra các rào cản bộ nhớ, đảm bảo tính nhất quán giữa các CPU của bộ nhớ.


0

Ngoại trừ cài đặt 'bool', không có guarrantee (ít nhất là trong c) rằng việc đọc hoặc viết một biến chỉ mất một hướng dẫn - hay đúng hơn là không thể bị gián đoạn khi đọc / viết nó


có bao nhiêu hướng dẫn để thiết lập một số nguyên 32 bit?
DXM

1
Bạn có thể mở rộng trên tuyên bố đầu tiên của bạn một chút. Bạn ngụ ý rằng chỉ một bool có thể được đọc / viết nguyên tử, nhưng điều đó không có ý nghĩa. Một "bool" không thực sự tồn tại trong phần cứng. Nó thường được thực hiện dưới dạng byte hoặc từ, vì vậy làm thế nào chỉ có thể boolcó thuộc tính này? Và bạn đang nói về việc tải từ bộ nhớ, thay đổi và đẩy trở lại bộ nhớ, hay bạn đang nói về mức độ đăng ký? Tất cả các lần đọc / ghi vào các thanh ghi đều không bị gián đoạn, nhưng tải mem thì không lưu trữ mem (vì chỉ có 2 hướng dẫn, sau đó ít nhất 1 lần nữa để thay đổi giá trị).
Corbin

1
Khái niệm về một lệnh đơn trong CPU siêu tốc / đa lõi / dự đoán chi nhánh / đa bộ nhớ cache là một chút khó khăn - nhưng tiêu chuẩn nói rằng chỉ 'bool' cần phải an toàn trước một chuyển đổi ngữ cảnh ở giữa đọc / ghi của một biến duy nhất. Có một boost :: Atomic bao bọc mutex xung quanh các loại khác và tôi nghĩ rằng c ++ 11 bổ sung thêm một số guarrantees luồng
Martin Beckett

Lời giải thích the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variablenên thực sự được thêm vào câu trả lời.
Sói

0

Bộ nhớ dùng chung.

Đó là định nghĩa của ... chủ đề : một loạt các quy trình đồng thời, với bộ nhớ dùng chung.

Nếu không có bộ nhớ chia sẻ, chúng thường được gọi là các quy trình UNIX-trường học cũ .
Tuy nhiên, họ có thể cần một khóa, khi truy cập vào một tệp được chia sẻ.

(bộ nhớ dùng chung trong các hạt giống UNIX thực sự thường được triển khai bằng cách sử dụng bộ mô tả tệp giả mạo đại diện cho địa chỉ bộ nhớ dùng chung)


0

Một CPU chạy một lệnh tại một thời điểm, nhưng nếu bạn có hai hoặc nhiều CPU thì sao?

Bạn đúng ở chỗ không cần khóa, nếu bạn có thể viết chương trình sao cho tận dụng các hướng dẫn nguyên tử: các lệnh mà việc thực thi không bị gián đoạn trên bộ xử lý đã cho và không bị nhiễu bởi các bộ xử lý khác.

Khóa được yêu cầu khi một số hướng dẫn cần được bảo vệ khỏi sự can thiệp và không có hướng dẫn nguyên tử tương đương.

Chẳng hạn, việc chèn một nút vào danh sách liên kết đôi đòi hỏi phải cập nhật một số vị trí bộ nhớ. Trước khi chèn và sau khi chèn, một số bất biến nhất định giữ cấu trúc của danh sách. Tuy nhiên, trong quá trình chèn, những bất biến đó tạm thời bị phá vỡ: danh sách đang ở trạng thái "đang xây dựng".

Nếu một luồng khác di chuyển qua danh sách trong khi bất biến, hoặc cũng cố gắng sửa đổi nó khi nó ở trạng thái như vậy, cấu trúc dữ liệu có thể sẽ bị hỏng và hành vi sẽ không thể đoán trước: có thể phần mềm sẽ bị sập hoặc tiếp tục với kết quả không chính xác. Do đó, điều cần thiết là các chủ đề bằng cách nào đó đồng ý tránh xa nhau khi danh sách đang được cập nhật.

Danh sách được thiết kế phù hợp có thể được thao tác với các hướng dẫn nguyên tử, do đó không cần khóa. Các thuật toán cho điều này được gọi là "khóa miễn phí". Tuy nhiên, lưu ý rằng các hướng dẫn nguyên tử thực sự là một hình thức khóa. Chúng được thực hiện đặc biệt trong phần cứng và hoạt động thông qua giao tiếp giữa các bộ xử lý. Chúng đắt hơn các hướng dẫn tương tự không phải là nguyên tử.

Trên các bộ đa xử lý thiếu sự sang trọng của các hướng dẫn nguyên tử, các nguyên thủy để loại trừ lẫn nhau phải được xây dựng từ các truy cập bộ nhớ đơn giản và các vòng lặp bỏ phiếu. Những vấn đề như vậy đã được giải quyết bởi những người như Edsger Dijkstra và Leslie Lamport.


FYI, tôi đã đọc các thuật toán không khóa để xử lý các cập nhật danh sách liên kết đôi chỉ bằng một phép so sánh và trao đổi duy nhất. Ngoài ra, tôi đã đọc một tờ giấy trắng về một cơ sở có vẻ như nó sẽ rẻ hơn nhiều về phần cứng so với trao đổi so sánh và trao đổi kép (được thực hiện trong năm 68040 nhưng không thực hiện trong các bộ xử lý 68xxx khác): mở rộng tải -linked / store-condition để cho phép hai tải được liên kết và các cửa hàng có điều kiện, nhưng với điều kiện là một quyền truy cập xảy ra giữa hai cửa hàng sẽ không quay trở lại đầu tiên. Đó là dễ dàng hơn nhiều để thực hiện hơn một-và-store đúp so sánh ...
supercat

... nhưng sẽ mang lại lợi ích tương tự khi cố gắng quản lý các cập nhật danh sách liên kết kép. Theo như tôi có thể nói, tải liên kết đôi không bắt kịp, nhưng chi phí phần cứng có vẻ khá rẻ nếu có bất kỳ nhu cầu nào.
supercat
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.