Đ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:
- Đọc số lần truy cập từ bộ nhớ vào thanh ghi bộ xử lý
- Tăng số đó.
- 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:
- Đọc giá trị của một biến động trực tiếp từ bộ nhớ.
- Tăng giá trị đó.
- 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.