12. Biết rõ Khi nào và Như thế nào để lập trình song song

Estimated reading: 16 minutes 273 views

Tóm tắt

An toàn luồng (Thread Safety): Nếu ứng dụng của bạn sử dụng nhiều luồng (thread) hoặc tiến trình (Process), hãy biết cách giảm thiểu việc chia sẻ đối tượng khi có thể (Mục 10) và phải biết chia sẻ các đối tượng đúng cách.

Thảo luận

Tính toán đồng thời (song song) là một lĩnh vực rộng lớn. Mục này tồn tại vì tính quan trọng của nó, nhưng chỉ một mục không thể diễn tả chi tiết và chúng tôi chỉ tóm tắt một số yếu tố cốt lõi. Những vấn đề quan trọng nhất bao gồm:

  • Tránh deadlock (khoá chết)
  • Tránh livelock (vòng lặp không kết thúc)
  • Tránh race condition (gây hỏng dữ liệu do lock không đủ hiệu lực)

Chuẩn C++ không đề cập dù chỉ một từ đến luồng (threads), tuy nhiên, C++ vẫn được dùng rộng rãi để viết mã đa luồng (multithreading) ổn định. Nếu chương trình của bạn phải chia sẻ dữ liệu giữa các thread, hãy đảm bảo thực hiện đúng cách và an toàn:

  • Tham khảo tài liệu của nền tảng mục tiêu để biết các cơ chế đồng bộ hoá có sẵn (synchronization primitives), như toán tử nguyên tử (atomic integer operations), bộ rào bộ nhớ (memory barriers) và khoá luồng (mutex).

  • Bọc các cơ chế đồng bộ hoá trong lớp trừu tượng của riêng bạn: Đây là một ý tưởng hay, đặc biệt nếu bạn cần tính toán đa nền tảng. Cách khác là bạn có thể sử dụng thư viện hỗ trợ (như pthreads [Butenhof97]).

  • Đảm bảo kiểu dữ liệu bạn sử dụng an toàn trong lập trình đa luồng, đặc biệt:
    • Mỗi đối tượng không chia sẻ phải độc lập: 2 thread có thể dùng 2 đối tượng khác nhau mà không cần lo lắng.
    • Tài liệu rõ ràng về cách chia sẻ đối tượng giữa các thread: Nhiều kiểu dữ liệu yêu cầu bạn phải tuần tự hóa (serialize) quyền truy cập vào các đối tượng chia sẻ, nhưng một số kiểu thì không. Những kiểu không yêu cầu tuần tự hóa thường được thiết kế để loại bỏ nhu cầu khóa, hoặc chúng tự thực hiện khóa bên trọng. Trong trường hợp thứ hai, bạn vẫn cần hiểu rõ giới hạn của mức độ khóa bên trong và các ảnh hưởng của nó.

Điều này áp dụng cho mọi kiểu dữ liệu, từ chuỗi ký tự (string), cấu trúc STL container (vector, map, set, v.v.), đến các kiểu dữ liệu khác (Nói thêm rằng, một số tác giả đưa ra những lời khuyên ngụ ý rằng các container chuẩn có phần đặc biệt. Không phải vậy! Container cũng chỉ như một đối tượng khác mà thôi). Hãy kiểm tra tài liệu của thư viện chuẩn để đảm bảo hỗ trợ lập trình đa luồng.

Khi thiết kế kiểu dữ liệu riêng để có thể sử dụng trong chương trình đa luồng, bạn phải đảm bảo 2 điều:

  • Các đối tượng riêng biệt phải hoạt động độc lập trong các thread khác nhau (Mỗi luồng có thể sử dụng các đối tượng khác nhau mà không cần lock). Lưu ý: loại có dữ liệu tĩnh (static) có thể sửa đổi thường không thể đảm bảo điều này.
  • Tài liệu hóa rõ ràng cách sử dụng an toàn khi chia sẻ cùng một đối tượng giữa các thread. Vấn đề cơ bản là làm thế nào để phân rõ trách nhiệm một cách chính xác (không có race condition hay deadlock) giữa các lớp và mã khách của nó.

Các lựa chọn chính bao gồm:

  • External locking (Khóa bên ngoài): Mã gọi (caller) chịu trách nhiệm khóa. Với cách tiếp cận này, mã sử dụng một đối tượng phải biết rõ liệu đối tượng đó có được chia sẻ giữa các luồng hay không, và nếu có, thì phải đảm bảo tuần tự hóa tất cả các thao tác trên đối tượng đó. Ví dụ, các kiểu dữ liệu chuỗi (string) thường áp dụng cơ chế khóa bên ngoài (hoặc  phương pháp immutable (bất biến) – đề cập mục thứ 3)
  • Internal locking (Khóa bên trong):

Mỗi đối tượng tự đảm bảo tuần tự hóa (serialize) mọi truy cập vào chính nó, thường bằng cách khóa mọi hàm thành viên công khai. Điều này giúp mã gọi (caller) không cần tự khóa khi sử dụng đối tượng. Ví dụ, các hàng đợi kiểu producer/consumer thường sử dụng cơ chế khóa bên trong, vì mục đích chính của chúng là được chia sẻ giữa nhiều luồng. Giao diện của chúng được thiết kế sao cho mức độ khóa phù hợp là trong suốt quá trình thực thi của từng hàm thành viên riêng lẻ (chẳng hạn như Push, Pop). Nhìn chung, phương pháp này chỉ phù hợp khi bạn biết chắc chắn hai điều:

    • Thứ nhất, bạn phải xác định trước rằng các đối tượng thuộc kiểu này gần như luôn luôn được chia sẻ giữa các luồng. Nếu không, bạn sẽ thực hiện các thao tác khóa không cần thiết, gây lãng phí hiệu suất. Lưu ý rằng hầu hết các kiểu dữ liệu không đáp ứng điều kiện này; trên thực tế, ngay cả trong các chương trình đa luồng phức tạp, phần lớn các đối tượng không bao giờ được chia sẻ giữa các luồng (và điều này là tốt – Mục 10).
    • Thứ hai, bạn phải xác định trước rằng việc khóa ở cấp độ từng hàm thành viên là mức độ phù hợp và đủ để đáp ứng nhu cầu của hầu hết người dùng.

Cụ thể, giao diện của kiểu dữ liệu nên ưu tiên các thao tác độc lập, có độ trừu tượng cao thay vì các thao tác đơn lẻ cần được ghép lại để tạo thành một logic lớn hơn. Nếu mã gọi thường xuyên cần khóa nhiều thao tác thay vì một thao tác duy nhất, thì việc khóa bên trong là không phù hợp. Trong trường hợp này, nếu mỗi hàm thành viên đều tự khóa, thì khi mã gọi thực hiện một loạt các thao tác, nó vẫn phải sử dụng khóa bên ngoài để đảm bảo tính nhất quán, khiến việc khóa bên trong trở nên dư thừa.

Ví dụ, hãy xem xét một kiểu container có phương thức trả về iterator, nhưng iterator này có thể trở nên không hợp lệ trước khi bạn kịp sử dụng nó. Hoặc một thuật toán thành viên như find có thể trả về một kết quả đúng tại thời điểm gọi, nhưng sau đó có thể trở nên sai trước khi bạn sử dụng nó. Một ví dụ khác là đoạn mã sau:

if (c.empty()) {

c.push_back(x);

}

Nếu c là một container được chia sẻ giữa các luồng, thì empty() có thể trả về true, nhưng trước khi push_back(x) được gọi, một luồng khác có thể đã thêm phần tử vào c, làm cho empty() trở nên không đúng nữa. Trong những trường hợp như vậy, người gọi vẫn phải sử dụng khóa bên ngoài để đảm bảo toàn bộ logic của họ được thực thi một cách nguyên tử. Điều này làm cho việc khóa bên trong ở từng hàm riêng lẻ trở nên lãng phí và không hiệu quả. Tóm lại việc khóa bên trong có liên quan trực tiếp đến giao diện công khai của kiểu dữ liệu:

      • Khóa bên trong thích hợp khi từng thao tác riêng lẻ của đối tượng là hoàn chỉnh và độc lập.
      • Điều này có nghĩa là cấp độ trừu tượng của kiểu dữ liệu phải được nâng cao và thiết kế rõ ràng hơn. Ví dụ, thay vì chỉ là một vector thông thường, đối tượng nên được thiết kế như một hàng đợi producer-consumer với các thao tác có độ trừu tượng cao hơn.
      • Cách tiếp cận đúng là kết hợp các thao tác cơ bản để tạo ra các thao tác chung, có ý nghĩa và đơn giản hơn.

Nếu việc kết hợp các thao tác có thể mang tính tùy ý, khiến bạn không thể gom nhóm các thao tác hợp lý vào một phương thức duy nhất, thì có hai cách tiếp cận khác:

      • Sử dụng mô hình callback: Thay vì để người gọi tự ghép các thao tác lại với nhau, hãy cung cấp một phương thức duy nhất nhận vào một “nhiệm vụ” mà người gọi muốn thực hiện, dưới dạng một đối tượng hàm hoặc command object. (Xem Mục 87 – 89.)
      • Cung cấp khả năng khóa nhúng trong giao diện: Tức là, thiết kế APIs theo cách cho phép người dùng kiểm soát việc khóa thay vì che giấu hoàn toàn bên trong lớp.
  • Thiết kế không khoá (Lock-free Design)

Không cần khoá nếu sử dụng đối tượng bất biến (immutable objects). Ví dụ, nếu chuỗi ký tự không bao giờ thay đổi sau khi được tạo, bạn có thể sử dụng nó mà không cần khóa. Một ví dụ phổ biến là các đối tượng bất biến, không cần khóa vì chúng không bao giờ thay đổi; ví dụ: đối với loại chuỗi bất biến, một đối tượng chuỗi không bao giờ được sửa đổi sau khi được tạo và mọi thao tác chuỗi đều dẫn đến việc tạo một chuỗi mới.

Mã gọi không nên phụ thuộc vào chi tiết triển khai. Mã gọi không nên cần biết về chi tiết triển khai của các kiểu dữ liệu bạn thiết kế (Mục 11). Nếu kiểu dữ liệu của bạn sử dụng các kỹ thuật chia sẻ dữ liệu ẩn (ví dụ: copy-on-write), bạn không nhất thiết phải chịu trách nhiệm về tất cả các vấn đề an toàn luồng có thể xảy ra. Tuy nhiên, bạn phải đảm bảo mức độ an toàn luồng tối thiểu cần thiết để mã gọi có thể hoạt động chính xác khi thực hiện đầy đủ các biện pháp xử lý thích hợp. Nói cách khác, kiểu dữ liệu của bạn phải an toàn ở mức tương đương với một kiểu không sử dụng chia sẻ dữ liệu bên trong. Ngoài ra, như đã đề cập, mọi kiểu dữ liệu được viết đúng đều phải đảm bảo rằng các đối tượng riêng biệt có thể được thao tác đồng thời trên các thread khác nhau mà không cần đồng bộ hóa.

Viết thư viện hỗ trợ đa luồng nhưng tối ưu hoá cho môi trường đơn luồng: Nếu bạn đang xây dựng một thư viện được sử dụng rộng rãi, hãy cân nhắc thiết kế các đối tượng sao cho chúng có thể hoạt động an toàn trong chương trình đa luồng (multi thread), nhưng không gây thêm chi phí, quá tải trong chương trình đơn luồng (single thread). Ví dụ, nếu bạn viết một thư viện chứa một kiểu dữ liệu sử dụng copy-on-write, và do đó phải thực hiện một số khóa nội bộ, hãy ưu tiên thiết lập cơ chế sao cho việc khóa có thể được loại bỏ trong các bản build đơn luồng. Các chiến lược phổ biến bao gồm sử dụng #ifdef hoặc triển khai “no-op” để tránh các thao tác khóa không cần thiết.

Tránh deadlock khi dùng nhiều lock:

  • Khi cần lấy nhiều lock, hãy đảm bảo tất cả mã trong chương trình đều lấy các lock đó theo cùng một thứ tự. Điều này giúp tránh tình huống deadlock xảy ra.
  • Việc giải phóng các lock có thể thực hiện theo bất kỳ thứ tự nào.
  • Một giải pháp phổ biến là lấy lock theo thứ tự tăng dần của địa chỉ bộ nhớ. Điều này cung cấp một trật tự duy nhất trên toàn ứng dụng, giúp tránh deadlock hiệu quả.

Tham khảo

[AlexandrescuO2a] • [AlexandrescuO4] • [Butenhof97] • [HenneyOO] • [HenneyOl] • [MeyersO4] • [SchmidtOl] • [StroustrupOO] §14.9 • [SutterO2] §16 • [SutterO4c]

Link

Leave a Comment

Chia sẻ:

12. Biết rõ Khi nào và Như thế nào để lập trình song song

Or copy link

CONTENTS