Làm thế nào hiệu quả là khóa một mutex mở khóa? Chi phí của một mutex là gì?


149

Trong một ngôn ngữ cấp thấp (C, C ++ hoặc bất cứ điều gì): Tôi có sự lựa chọn ở giữa hoặc có một loạt các biến thể (như những gì pthread mang lại cho tôi hoặc bất cứ thứ gì mà thư viện hệ thống gốc cung cấp) hoặc một cái duy nhất cho một đối tượng.

Làm thế nào hiệu quả để khóa một mutex? Tức là có bao nhiêu hướng dẫn trình biên dịch có khả năng và chúng mất bao nhiêu thời gian (trong trường hợp mutex được mở khóa)?

Một mutex có giá bao nhiêu? Nó có phải là một vấn đề để thực sự có nhiều đột biến? Hoặc tôi có thể ném bao nhiêu biến mutex trong mã của mình khi tôi có intcác biến và nó không thực sự quan trọng?

(Tôi không chắc có bao nhiêu sự khác biệt giữa các phần cứng khác nhau. Nếu có, tôi cũng muốn biết về chúng. Nhưng chủ yếu, tôi quan tâm đến phần cứng phổ biến.)

Vấn đề là, bằng cách sử dụng nhiều mutex mà mỗi cái chỉ bao gồm một phần của đối tượng thay vì một mutex duy nhất cho toàn bộ đối tượng, tôi có thể an toàn nhiều khối. Và tôi tự hỏi tôi nên đi bao xa về điều này. Tức là tôi nên cố gắng an toàn bất kỳ khối nào có thể thực sự càng xa càng tốt, bất kể phức tạp hơn bao nhiêu và điều này có nghĩa là bao nhiêu đột biến nữa?


Bài đăng trên blog của WebKits (2016) về khóa rất liên quan đến câu hỏi này và giải thích sự khác biệt giữa khóa kéo, khóa thích ứng, futex, v.v.


Điều này sẽ được thực hiện và kiến ​​trúc cụ thể. Một số mutexes sẽ có giá gần như không có gì nếu có hỗ trợ phần cứng riêng, số khác sẽ có giá rất cao. Không thể trả lời nếu không có thêm thông tin.
Gian

2
@Gian: Vâng, tất nhiên tôi ngụ ý câu hỏi con này trong câu hỏi của tôi. Tôi muốn biết về phần cứng phổ biến nhưng cũng có ngoại lệ đáng chú ý nếu có.
Albert

Tôi thực sự không thấy hàm ý đó ở bất cứ đâu. Bạn hỏi về "hướng dẫn trình biên dịch" - câu trả lời có thể là từ 1 lệnh đến mười nghìn hướng dẫn tùy thuộc vào kiến ​​trúc bạn đang nói về.
Gian

15
@Gian: Sau đó, xin vui lòng cho chính xác câu trả lời này. Vui lòng cho biết những gì thực sự có trên x86 và amd64, vui lòng đưa ra một ví dụ cho kiến ​​trúc có 1 hướng dẫn và đưa ra một trong đó có giá 10k. Không rõ ràng rằng tôi muốn biết điều đó từ câu hỏi của tôi?
Albert

Câu trả lời:


120

Tôi có sự lựa chọn ở giữa hoặc có một loạt các trường hợp đột biến hoặc một trường hợp duy nhất cho một đối tượng.

Nếu bạn có nhiều luồng và việc truy cập vào đối tượng xảy ra thường xuyên, thì nhiều khóa sẽ tăng tính song song. Với chi phí bảo trì, vì khóa nhiều hơn có nghĩa là gỡ lỗi khóa nhiều hơn.

Làm thế nào hiệu quả để khóa một mutex? Tức là có bao nhiêu hướng dẫn trình biên dịch có khả năng và chúng mất bao nhiêu thời gian (trong trường hợp mutex được mở khóa)?

Các hướng dẫn trình biên dịch chính xác là chi phí tối thiểu của một mutex - đảm bảo tính liên kết bộ nhớ / bộ nhớ cache là chi phí chính. Và ít thường xuyên hơn một khóa cụ thể được thực hiện - tốt hơn.

Mutex được tạo thành từ hai phần chính (đơn giản hóa): (1) một lá cờ cho biết liệu mutex có bị khóa hay không và (2) hàng đợi.

Thay đổi cờ chỉ là một vài hướng dẫn và thường được thực hiện mà không cần gọi hệ thống. Nếu mutex bị khóa, syscall sẽ xảy ra để thêm chuỗi cuộc gọi vào hàng đợi và bắt đầu chờ. Mở khóa, nếu hàng đợi chờ trống, giá rẻ nhưng nếu không thì cần một tòa nhà để đánh thức một trong các quy trình chờ. (Trên một số hệ thống các tòa nhà giá rẻ / nhanh được sử dụng để triển khai các mutexes, chúng trở thành các cuộc gọi hệ thống chậm (bình thường) chỉ trong trường hợp tranh chấp.)

Khóa mutex mở khóa thực sự rẻ. Mở khóa mutex w / o ganh đua cũng rẻ.

Một mutex có giá bao nhiêu? Nó có phải là một vấn đề để thực sự có nhiều đột biến? Hoặc tôi có thể ném bao nhiêu biến mutex trong mã của mình khi tôi có biến int và nó không thực sự quan trọng?

Bạn có thể ném bao nhiêu biến mutex vào mã của mình nếu muốn. Bạn chỉ bị giới hạn bởi số lượng bộ nhớ mà ứng dụng của bạn có thể phân bổ.

Tóm lược. Khóa không gian người dùng (đặc biệt là các mutexes) có giá rẻ và không chịu bất kỳ giới hạn hệ thống nào. Nhưng quá nhiều trong số họ nói lên cơn ác mộng cho việc gỡ lỗi. Bảng đơn giản:

  1. Ít khóa hơn có nghĩa là nhiều sự tranh chấp (tòa nhà chậm, quầy CPU) và sự song song ít hơn
  2. Ít khóa hơn có nghĩa là ít vấn đề hơn khi gỡ lỗi các vấn đề đa luồng.
  3. Nhiều khóa hơn có nghĩa là ít tranh chấp và song song cao hơn
  4. Nhiều khóa hơn có nghĩa là nhiều cơ hội chạy vào bế tắc không thể vượt qua.

Một kế hoạch khóa cân bằng cho ứng dụng nên được tìm thấy và duy trì, thường cân bằng giữa # 2 và # 3.


(*) Vấn đề với các mutex ít bị khóa thường xuyên là nếu bạn có quá nhiều khóa trong ứng dụng của mình, điều đó sẽ khiến cho nhiều lưu lượng giữa CPU / lõi bị xóa bộ nhớ mutex khỏi bộ đệm dữ liệu của các CPU khác để đảm bảo Sự liên kết bộ nhớ cache. Các lần xóa bộ đệm giống như các ngắt trọng lượng nhẹ và được xử lý bởi CPU trong suốt - nhưng chúng giới thiệu cái gọi là quầy hàng (tìm kiếm "gian hàng").

Và các quầy hàng là những gì làm cho mã khóa chạy chậm, thường không có bất kỳ dấu hiệu rõ ràng nào tại sao ứng dụng chậm. (Một số vòm cung cấp số liệu thống kê lưu lượng giữa CPU / lõi, một số thì không.)

Để tránh vấn đề, mọi người thường sử dụng số lượng lớn các khóa để giảm xác suất xảy ra tranh chấp khóa và để tránh gian hàng. Đó là lý do tại sao khóa không gian người dùng giá rẻ, không bị giới hạn hệ thống, tồn tại.


Cảm ơn, điều đó chủ yếu trả lời câu hỏi của tôi. Tôi không biết rằng kernel (ví dụ kernel Linux) xử lý các mutexes và bạn điều khiển chúng thông qua các tòa nhà. Nhưng khi Linux tự quản lý việc chuyển đổi lịch trình và bối cảnh, điều này có ý nghĩa. Nhưng bây giờ tôi có một trí tưởng tượng sơ bộ về những gì khóa / mở khóa mutex sẽ làm trong nội bộ.
Albert

2
@ Albert: Ồ. Tôi quên các công tắc ngữ cảnh ... Các công tắc bối cảnh quá tốn kém về hiệu suất. Nếu việc mua lại khóa thất bại và luồng phải chờ, đó là quá một nửa của chuyển đổi ngữ cảnh. Bản thân CS rất nhanh, nhưng vì CPU có thể được sử dụng bởi một số quy trình khác, nên bộ đệm sẽ chứa đầy dữ liệu của người ngoài hành tinh. Sau khi luồng cuối cùng có được khóa, nhiều khả năng CPU sẽ phải tải lại khá nhiều thứ từ RAM một lần nữa.
Dummy00001

@ Dummy00001 Chuyển sang quy trình khác có nghĩa là bạn phải thay đổi ánh xạ bộ nhớ của CPU. Đó không phải là quá rẻ.
tò mò

27

Tôi muốn biết điều tương tự, vì vậy tôi đã đo nó. Trên hộp của tôi (AMD FX (tm) -8150 Bộ xử lý tám lõi tốc độ 3.612361 GHz), khóa và mở khóa một mutex đã được mở khóa trong dòng bộ nhớ cache của riêng nó và đã được lưu vào bộ nhớ cache, mất 47 đồng hồ (13 ns).

Do đồng bộ hóa giữa hai lõi (tôi đã sử dụng CPU # 0 và # 1), tôi chỉ có thể gọi một cặp khóa / mở khóa một lần trong 102 ns trên hai luồng, cứ sau 51 giây, người ta có thể kết luận rằng phải mất khoảng 38 ns ns để phục hồi sau khi một luồng thực hiện mở khóa trước khi luồng tiếp theo có thể khóa lại.

Chương trình mà tôi đã sử dụng để điều tra điều này có thể được tìm thấy ở đây: https://github.com/CarloWood/ai-statefultask-testsuite/blob/b69b112e2e91d35b56a39f41809d3e3de2f9e4b8/src/

Lưu ý rằng nó có một vài giá trị được mã hóa cụ thể cho hộp của tôi (xrange, yrange và rdtsc trên đầu), vì vậy bạn có thể phải thử nghiệm với nó trước khi nó hoạt động cho bạn.

Biểu đồ mà nó tạo ra ở trạng thái đó là:

nhập mô tả hình ảnh ở đây

Điều này cho thấy kết quả của điểm chuẩn chạy trên mã sau đây:

uint64_t do_Ndec(int thread, int loop_count)
{
  uint64_t start;
  uint64_t end;
  int __d0;

  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (start) : : "%rdx");
  mutex.lock();
  mutex.unlock();
  asm volatile ("rdtsc\n\tshl $32, %%rdx\n\tor %%rdx, %0" : "=a" (end) : : "%rdx");
  asm volatile ("\n1:\n\tdecl %%ecx\n\tjnz 1b" : "=c" (__d0) : "c" (loop_count - thread) : "cc");
  return end - start;
}

Hai cuộc gọi ndtsc đo số lượng đồng hồ cần thiết để khóa và mở khóa 'mutex' (với tổng số 39 đồng hồ cho các cuộc gọi của ndtsc trên hộp của tôi). Asm thứ ba là một vòng lặp trì hoãn. Kích thước của vòng lặp trễ là 1 đếm nhỏ hơn cho luồng 1 so với luồng 0, do đó, luồng 1 nhanh hơn một chút.

Hàm trên được gọi trong một vòng lặp chặt chẽ có kích thước 100.000. Mặc dù chức năng này nhanh hơn một chút đối với luồng 1, cả hai vòng đều đồng bộ hóa do cuộc gọi đến mutex. Điều này có thể nhìn thấy trong biểu đồ từ thực tế là số lượng đồng hồ được đo cho cặp khóa / mở khóa lớn hơn một chút cho luồng 1, để tính độ trễ ngắn hơn trong vòng lặp bên dưới nó.

Trong biểu đồ trên, điểm dưới cùng bên phải là một phép đo có độ trễ loop_count là 150, và sau đó theo các điểm ở phía dưới, về phía bên trái, loop_count được giảm đi mỗi lần đo. Khi nó trở thành 77, hàm được gọi cứ 102 ns trong cả hai luồng. Nếu loop_count sau đó bị giảm hơn nữa thì không thể đồng bộ hóa các luồng và mutex bắt đầu thực sự bị khóa hầu hết thời gian, dẫn đến số lượng đồng hồ tăng lên để thực hiện khóa / mở khóa. Ngoài ra thời gian trung bình của cuộc gọi chức năng tăng vì điều này; Vì vậy, các điểm cốt truyện bây giờ đi lên và về phía bên phải một lần nữa.

Từ điều này, chúng tôi có thể kết luận rằng việc khóa và mở khóa một mutex cứ sau 50 ns không phải là vấn đề trong hộp của tôi.

Tất cả trong tất cả kết luận của tôi là câu trả lời cho câu hỏi của OP là thêm nhiều mutexes sẽ tốt hơn miễn là điều đó dẫn đến sự tranh chấp ít hơn.

Cố gắng khóa mutexes càng ngắn càng tốt. Lý do duy nhất để đặt chúng - bên ngoài một vòng lặp sẽ là nếu vòng lặp đó lặp lại nhanh hơn một lần trong mỗi 100 ns (hay đúng hơn là số luồng muốn chạy vòng lặp đó cùng lúc 50 lần) hoặc khi 13 lần kích thước vòng lặp chậm hơn so với độ trễ bạn nhận được bằng sự tranh chấp.

EDIT: Bây giờ tôi đã hiểu biết nhiều hơn về chủ đề này và bắt đầu nghi ngờ về kết luận mà tôi đã trình bày ở đây. Trước hết, CPU 0 và 1 hóa ra là siêu luồng; mặc dù AMD tuyên bố có 8 lõi thực sự, nhưng chắc chắn có điều gì đó rất đáng nghi vì độ trễ giữa hai lõi khác lớn hơn nhiều (nghĩa là 0 và 1 tạo thành một cặp, cũng như 2 và 3, 4 và 5, và 6 và 7 ). Thứ hai, std :: mutex được triển khai theo cách nó quay khóa một chút trước khi thực sự thực hiện các cuộc gọi hệ thống khi không nhận được khóa ngay lập tức trên một mutex (điều này chắc chắn sẽ rất chậm). Vì vậy, những gì tôi đã đo được ở đây là sự bão hòa lý tưởng tuyệt đối nhất và trong thực tế việc khóa và mở khóa có thể mất nhiều thời gian hơn cho mỗi lần khóa / mở khóa.

Tóm lại, một mutex được thực hiện với nguyên tử. Để đồng bộ hóa các nguyên tử giữa các lõi, một bus nội bộ phải được khóa để đóng băng dòng bộ đệm tương ứng trong vài trăm chu kỳ xung nhịp. Trong trường hợp không thể lấy được khóa, một cuộc gọi hệ thống phải được thực hiện để đưa luồng vào chế độ ngủ; điều đó rõ ràng là rất chậm (các cuộc gọi hệ thống theo thứ tự 10 mircos giây). Thông thường đó không thực sự là vấn đề vì dù sao thì luồng đó cũng phải ngủ-- nhưng nó có thể là một vấn đề với sự tranh chấp cao trong đó một luồng không thể có được khóa trong thời gian mà nó thường quay và hệ thống cũng gọi, nhưng CÓ THỂ mất khóa ngay sau đó. Ví dụ: nếu một số luồng khóa và mở khóa một mutex trong một vòng lặp chặt chẽ và mỗi luồng giữ khóa trong 1 micro giây hoặc lâu hơn, sau đó họ có thể bị chậm lại rất nhiều bởi thực tế là họ liên tục bị ngủ và thức dậy một lần nữa. Ngoài ra, một khi một luồng ngủ và một luồng khác phải đánh thức nó, luồng đó phải thực hiện một cuộc gọi hệ thống và bị trì hoãn ~ 10 micro giây; do đó, độ trễ này xảy ra trong khi mở khóa một mutex khi một luồng khác đang chờ mutex đó trong kernel (sau khi quay quá lâu).


10

Điều này phụ thuộc vào những gì bạn thực sự gọi là "mutex", chế độ hệ điều hành và vv

Tại tối thiểu đó là một chi phí của một hoạt động bộ nhớ đan cài. Đây là một hoạt động tương đối nặng (so với các lệnh biên dịch mã nguyên thủy khác).

Tuy nhiên, điều đó có thể cao hơn rất nhiều. Nếu cái mà bạn gọi là "mutex" là một đối tượng kernel (tức là - đối tượng được quản lý bởi HĐH) và chạy trong chế độ người dùng - thì mọi thao tác trên nó đều dẫn đến một giao dịch chế độ kernel, rất nặng.

Ví dụ: trên bộ xử lý Intel Core Duo, Windows XP. Hoạt động liên khóa: mất khoảng 40 chu kỳ CPU. Cuộc gọi chế độ hạt nhân (tức là cuộc gọi hệ thống) - khoảng 2000 chu kỳ CPU.

Nếu đây là trường hợp - bạn có thể xem xét sử dụng các phần quan trọng. Đó là sự kết hợp của một mutex kernel và truy cập bộ nhớ lồng vào nhau.


7
Các phần quan trọng của Windows gần hơn với mutexes. Họ có ngữ nghĩa mutex thường xuyên, nhưng chúng là quá trình cục bộ. Phần cuối cùng làm cho chúng nhanh hơn rất nhiều, vì chúng có thể được xử lý hoàn toàn trong quy trình của bạn (và do đó là mã chế độ người dùng).
MSalters

2
Con số sẽ hữu ích hơn nếu số lượng chu kỳ CPU của các hoạt động phổ biến (ví dụ: số học / if-other / cache-miss / indirection) cũng được cung cấp để so sánh. .... Sẽ còn tuyệt vời hơn nếu có một số tài liệu tham khảo về số lượng. Trong internet, rất khó để tìm thấy thông tin như vậy.
javaLover

@javaLover Hoạt động không chạy theo chu kỳ; họ chạy trên các đơn vị số học cho một số chu kỳ. Nó rất khác biệt. Chi phí của bất kỳ hướng dẫn nào trong thời gian không phải là một số lượng xác định, chỉ có chi phí sử dụng tài nguyên. Những tài nguyên này được chia sẻ. Tác động của các hướng dẫn bộ nhớ phụ thuộc rất nhiều vào bộ nhớ đệm, v.v.
tò mò

@cantlyguy Đồng ý. Tôi đã không rõ ràng. Tôi muốn câu trả lời như std::mutexthời gian sử dụng trung bình (tính bằng giây) gấp 10 lần int++. Tuy nhiên, tôi biết thật khó để trả lời vì nó phụ thuộc rất nhiều vào rất nhiều thứ.
javaLover

6

Chi phí sẽ khác nhau tùy thuộc vào việc thực hiện nhưng bạn nên ghi nhớ hai điều:

  • chi phí rất có thể sẽ là tối thiểu vì đây là một hoạt động khá nguyên thủy và nó sẽ được tối ưu hóa càng nhiều càng tốt do mô hình sử dụng của nó (được sử dụng rất nhiều ).
  • không quan trọng là nó đắt như thế nào vì bạn cần sử dụng nó nếu bạn muốn hoạt động đa luồng an toàn. Nếu bạn cần nó, thì bạn cần nó.

Trên các hệ thống xử lý đơn, bạn thường chỉ có thể vô hiệu hóa các ngắt đủ lâu để thay đổi dữ liệu nguyên tử. Các hệ thống đa bộ xử lý có thể sử dụng chiến lược thử nghiệm và thiết lập .

Trong cả hai trường hợp, các hướng dẫn đều tương đối hiệu quả.

Về việc bạn nên cung cấp một mutex duy nhất cho một cấu trúc dữ liệu lớn hay có nhiều mutexes, một cho mỗi phần của nó, đó là một hành động cân bằng.

Khi có một mutex duy nhất, bạn có nguy cơ tranh chấp cao hơn giữa nhiều luồng. Bạn có thể giảm thiểu rủi ro này bằng cách có một mutex cho mỗi phần nhưng bạn không muốn gặp phải tình huống trong đó một luồng phải khóa 180 mutexes để thực hiện công việc của nó :-)


1
Vâng, nhưng làm thế nào hiệu quả? Đây có phải là một hướng dẫn máy duy nhất? Hay khoảng 10? Hay khoảng 100? 1000? Hơn? Tất cả điều này vẫn hiệu quả, tuy nhiên có thể tạo ra sự khác biệt trong các tình huống cực đoan.
Albert

1
Vâng, điều đó phụ thuộc hoàn toàn vào việc thực hiện. Bạn có thể tắt ngắt, kiểm tra / đặt số nguyên và kích hoạt lại các ngắt trong một vòng lặp trong khoảng sáu hướng dẫn máy. Kiểm tra và thiết lập có thể được thực hiện trong khoảng bao nhiêu vì các bộ xử lý có xu hướng cung cấp điều đó như một hướng dẫn duy nhất.
paxdiablo

Kiểm tra và thiết lập khóa xe buýt là một hướng dẫn (khá dài) trên x86. Phần còn lại của máy móc để sử dụng nó khá nhanh (đã thực hiện thử nghiệm thành công? Đây là một câu hỏi mà CPU làm rất nhanh) nhưng đó là độ dài của khóa xe buýt thực sự quan trọng vì nó là phần chặn mọi thứ. Các giải pháp với các ngắt chậm hơn nhiều, vì việc thao tác chúng thường bị hạn chế trong nhân hệ điều hành để ngăn chặn các cuộc tấn công DoS tầm thường.
Donal Fellows

BTW, không sử dụng thả / phản hồi như một phương tiện để có năng suất luồng cho người khác; đó là một chiến lược hấp dẫn trên một hệ thống đa lõi. (Đó là một trong những điều tương đối ít mà CPython mắc phải.)
Donal Fellows

@Donal: Ý bạn là gì khi thả / phản ứng? Điều đó nghe có vẻ quan trọng; bạn có thể cho tôi thêm thông tin về điều đó?
Albert

5

Tôi hoàn toàn mới với pthreads và mutex, nhưng tôi có thể xác nhận từ thử nghiệm rằng chi phí khóa / mở khóa một mutex là gần như không có khi tranh chấp, nhưng khi có sự tranh chấp, chi phí chặn là cực kỳ cao. Tôi đã chạy một mã đơn giản với một nhóm luồng trong đó nhiệm vụ chỉ là tính tổng trong một biến toàn cục được bảo vệ bởi khóa mutex:

y = exp(-j*0.0001);
pthread_mutex_lock(&lock);
x += y ;
pthread_mutex_unlock(&lock);

Với một luồng, chương trình tính tổng 10.000.000 giá trị gần như tức thời (chưa đến một giây); với hai luồng (trên MacBook có 4 lõi), cùng một chương trình mất 39 giâ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.