Tổng Quát Về Lập Trình đa Luồng Trong Modern C++

Nội dung

Toggle
  • Kỹ thuật đồng thời (concurrency) là gì?
  • Lịch sử của C++ Concurrency
  • Đồng thời (concurrency) so với song song (parallelism)
  • Các phương thức để thực hiện kỹ thuật đồng thời
    • Lập trình đa luồng với C++
    • Lập trình song song
    • Ví dụ về đa luồng
      • Thực thi trên một luồng
      • Thực thi trên nhiều luồng
  • Một số ứng dụng trong thực tế

Trong môi trường công nghệ hiện đại, concurrency (đồng thời) đã trở thành một kỹ năng thiết yếu cho tất cả các lập trình viên C++. Khi các chương trình tiếp tục trở nên phức tạp hơn, các máy tính được thiết kế với nhiều lõi CPU hơn. Để thiết kế hiệu quả các chương trình này, các nhà phát triển phải viết code sử dụng các kiến trúc đa lõi này. Điều này được thực hiện thông qua lập trình đồng thời, một kỹ thuật mã hóa đảm bảo việc sử dụng tất cả các lõi và tối đa hóa khả năng của máy. Để bạn làm quen với lập trình đồng thời và đa luồng, tôi sẽ hướng dẫn bạn qua tất cả các định nghĩa và ví dụ thực tế bạn cần biết.

Kỹ thuật đồng thời (concurrency) là gì?

Concurrency xảy ra khi nhiều bản sao của một chương trình chạy đồng thời trong khi giao tiếp với nhau. Nói một cách đơn giản, đồng thời là khi hai nhiệm vụ chồng chéo nhau. Một ứng dụng đồng thời đơn giản sẽ sử dụng một máy duy nhất để lưu trữ lệnh chương trình, nhưng quá trình đó được thực thi bởi nhiều luồng khác nhau. Điều này tạo ra một loại luồng điều khiển, trong đó mỗi luồng thực hiện lệnh của nó trước khi chuyển sang luồng tiếp theo.

Điều này cho phép các luồng hoạt động độc lập và đưa ra quyết định dựa trên luồng trước đó. Một số vấn đề có thể phát sinh đồng thời làm cho nó khó thực hiện.

Ví dụ, một cuộc đua (race condition) dữ liệu là một vấn đề phổ biến mà bạn có thể gặp phải trong các quy trình đồng thời và đa luồng của C++. Các cuộc đua dữ liệu trong C++ xảy ra khi ít nhất hai luồng có thể truy cập đồng thời vào một biến hoặc vị trí bộ nhớ và ít nhất một trong các luồng đó cố gắng truy cập vào biến đó. Điều này có thể dẫn đến hành vi không xác định. Bất kể những thách thức của nó, kỹ thuật này là rất quan trọng để xử lý nhiều nhiệm vụ cùng một lúc.

Lịch sử của C++ Concurrency

C++11 là tiêu chuẩn C++ đầu tiên để giới thiệu kỹ thuật đồng thời, bao gồm các luồng, mô hình bộ nhớ, các biến có điều kiện, mutex và hơn thế nữa. Tiêu chuẩn C++11 thay đổi mạnh mẽ với C++17. Việc bổ sung các thuật toán song song trong Thư viện mẫu tiêu chuẩn (STL) đã cải thiện đáng kể mã đồng thời.

Đồng thời (concurrency) so với song song (parallelism)

Đồng thời và song song thường bị lẫn lộn, nhưng điều quan trọng là phải hiểu sự khác biệt. Trong song song, chúng ta chạy đồng thời nhiều bản sao của cùng một chương trình, nhưng chúng được thực thi trên các dữ liệu khác nhau. Ví dụ: bạn có thể sử dụng song song để gửi yêu cầu đến các trang web khác nhau nhưng cung cấp cho mỗi bản sao của chương trình một bộ URL khác nhau. Các bản sao này không nhất thiết phải liên lạc với nhau, nhưng chúng chạy song song cùng lúc. Như chúng tôi đã giải thích ở trên, lập trình đồng thời liên quan đến một vị trí bộ nhớ dùng chung và các luồng khác nhau thực sự đã đọc thông tin được cung cấp bởi các luồng trước đó.

Ảnh chụp từ Espresso, “What is concurrency programming?”

Các phương thức để thực hiện kỹ thuật đồng thời

Trong C++, hai cách phổ biến nhất để thực hiện đồng thời là thông qua đa luồng và song song. Mặc dù chúng có thể được sử dụng trong các ngôn ngữ lập trình khác, C++ nổi bật về khả năng đồng thời với chi phí thấp hơn chi phí trung bình cũng như khả năng với tập lệnh phức tạp. Dưới đây, chúng tôi sẽ khám phá lập trình đồng thời và đa luồng trong lập trình C++.

Lập trình đa luồng với C++

Đa luồng trong C++ liên quan đến việc tạo và sử dụng các đối tượng luồng, được xem như std::thread trong code, để thực hiện các tác vụ phụ được ủy nhiệm một cách độc lập. Khi tạo, các luồng được truyền một hàm để hoàn thành và tùy chọn một số tham số cho hàm đó.

Ảnh được lấy từ “Medium, [C++] Concurrency” của Valentina

Mặc dù mỗi luồng riêng lẻ chỉ có thể hoàn thành một chức năng tại một thời điểm, nhóm luồng cho phép chúng ta tái chế và tái sử dụng các đối tượng luồng để tạo cho các chương trình ảo giác về đa nhiệm không giới hạn. Điều này không chỉ tận dụng nhiều lõi CPU mà còn cho phép nhà phát triển kiểm soát số lượng tác vụ được thực hiện bằng cách thao tác kích thước nhóm luồng. Điều này đảm bảo rằng chương trình sử dụng tài nguyên máy tính một cách hiệu quả mà không làm quá tải hệ thống.

Để hiểu rõ hơn về chuỗi chủ đề, hãy xem xét mối quan hệ của ong thợ với một nữ hoàng tổ ong; Nữ hoàng (chương trình) có một mục tiêu rộng lớn hơn để hoàn thành (sự sống sót của tổ ong) trong khi các ong thợ (các luồng) chỉ có nhiệm vụ cá nhân của họ do nữ hoàng đưa ra. Một khi những nhiệm vụ này được hoàn thành, những con ong trở lại với nữ hoàng để được hướng dẫn thêm. Tại bất kỳ thời điểm nào, có một số lượng các con ong này được chỉ huy bởi nữ hoàng, đủ để tận dụng tất cả không gian tổ ong của nó mà không quá đông đúc.

Lập trình song song

Việc tạo các luồng khác nhau thường tốn kém về cả thời gian và chi phí bộ nhớ cho chương trình; một chi phí mà khi xử lý trong thời gian ngắn, các hàm đơn giản hơn, đôi khi có thể không đáng giá. Trong những lúc như vậy, các nhà phát triển thay vào đó có thể sử dụng cách thực thi song song, một cách tạo ra các hàm ứng viên cho lập trình đồng thời mà không tạo ra các luồng rõ ràng.

Ở mức cơ bản nhất, có hai thứ có thể được mã hóa thành hàm. Đầu tiên là song song, gợi ý cho trình biên dịch rằng hàm được hoàn thành đồng thời với các hàm song song khác (tuy nhiên trình biên dịch có thể ghi đè đề xuất này nếu tài nguyên bị giới hạn). Cái khác là tuần tự, có nghĩa là hàm phải được hoàn thành riêng lẻ. Các hàm song song có thể tăng tốc đáng kể các hoạt động vì chúng tự động sử dụng nhiều tài nguyên CPU của máy tính.

Tuy nhiên, tốt nhất là cho các hàm có ít tương tác với các hàm khác; tức là không phụ thuộc vào đầu ra của các hàm khác cũng như không cố gắng chỉnh sửa cùng một dữ liệu. Điều này là do khi chúng được làm việc đồng thời, không có cách nào để biết hàm nào sẽ hoàn thành trước, có nghĩa là kết quả không thể đoán trước trừ khi sử dụng các kỹ thuật đồng bộ hóa như mutex hoặc biến điều kiện.

Hãy tưởng tượng chúng ta có hai biến A, B và tạo các hàm addA, addB, rồi thêm 2 vào giá trị của chúng. Chúng ta có thể làm như vậy với tính toán song song, vì hành vi của addA độc lập với hành vi của hàm song song khác addB, và do đó không có vấn đề gì xảy ra. Tuy nhiên, nếu cả hai hàm đều tác động đến cùng một biến, thay vào đó chúng ta sẽ sử dụng thực thi tuần tự. Giả sử rằng, chúng ta có một hàm nhân A với 2, DoubleA và hàm khác thêm B vào A, addBA. Trong trường hợp này, chúng ta sẽ không thể sử dụng thực thi song song vì kết quả của hàm này phụ thuộc vào hàm được hoàn thành trước đó và do đó sẽ dẫn đến vấn đề điều kiện cuộc đua.

Mặc dù cả đa luồng và song song là các khái niệm hữu ích để thực hiện đồng thời trong chương trình C++, nhưng đa luồng được áp dụng rộng rãi hơn do khả năng xử lý các hoạt động phức tạp của nó. Trong phần tiếp theo, chúng tôi sẽ xem xét một ví dụ mã về đa luồng ở mức cơ bản nhất.

Ví dụ về đa luồng

Thực thi trên một luồng

Vì tất cả các luồng khi khởi tao phải được cung cấp một hàm để thực thi trong luồng, nên trước tiên chúng ta phải khai báo một hàm cho nó. Chúng ta sẽ đặt tên cho hàm này là print với các đối số int và string khi được gọi. Khi được thực thi, hàm này sẽ chỉ in ra các giá trị dữ liệu được truyền vào.

voidprint(intn, conststd::string&str) { std::cout << "Printing integer: " << n << std::endl; std::cout << "Printing string: " << str << std::endl; }

Tiếp theo, chúng ta sẽ khởi tạo một luồng và để nó thực thi hàm trên. Để làm điều này, chúng ta sẽ có hàm main, trình thực thi mặc định có trong tất cả các ứng dụng C++, khởi tạo luồng cho chức năng in. Sau đó, chúng ta sử dụng một lệnh, join(), tạm dừng xử lý trên luồng chính của hàm main cho đến khi luồng được chỉ định, trong trường hợp này là t1, đã hoàn thành nhiệm vụ. Nếu không có join() ở đây, luồng chính sẽ hoàn thành nhiệm vụ trước khi t1 hoàn thành in, dẫn đến lỗi.

intmain() { std::thread t1(print, 10, "Educative.blog"); t1.join(); return0; }

Thực thi trên nhiều luồng

Mặc dù kết quả của ví dụ luồng đơn ở trên có thể dễ dàng được sao chép mà không cần sử dụng mã đa luồng, chúng ta thực sự có thể thấy các lợi ích của thực thi đồng thời khi chúng ta phải print nhiều lần với các bộ dữ liệu khác nhau. Nếu không có đa luồng, điều này sẽ được thực hiện bằng cách chỉ cần lặp lại hàm print ở luồng chính cho đến khi hoàn thành.

Để thực hiện điều này với ý nghĩa đồng thời, thay vào đó, chúng ta sử dụng vòng lặp for để khởi tạo nhiều luồng, chuyển cho chúng hàm print và đối số, sau đó chúng se được thực thi một cách đồng thời. Tùy chọn đa luồng này sẽ nhanh hơn khi chỉ sử dụng luồng chính vì nhiều CPU đang được sử dụng. Sự khác biệt về thời gian chạy giữa các giải pháp đa luồng và không đa luồng ngày càng tăng khi cần thực thi hàm print nhiều lần hơn. Hãy để xem một phiên bản nhiều luồng của đoạn chương trình trên sẽ như thế nào:

voidprint(intn, conststd::string&str) { std::string msg = std::to_string(n) + " : " + str; std::cout << msg << std::endl; } intmain() { std::vector<std::string> s = { "Educative.blog", "Educative", "courses", "are great" }; std::vector<std::thread> threads; for (int i = 0; i < s.size(); i++) { threads.push_back(std::thread(print, i, s[i])); } for (auto &th : threads) { th.join(); } return0; }

Một số ứng dụng trong thực tế

Các chương trình đa luồng rất là phổ biến trong các hệ thống kinh doanh hiện đại, trên thực tế, bạn có thể sẻ phải sử dụng một số phiên bản phức tạp hơn của các chương trình trên trong cuộc sống hàng ngày của bạn.

Một ví dụ có thể thấy là một máy chủ email, trả về nội dung hộp thư khi người dùng yêu cầu. Với chương trình này, chúng ta không có cách nào để biết có bao nhiêu người sẽ yêu cầu thư của họ tại bất kỳ thời điểm nào. Bằng cách sử dụng nhóm luồng, chương trình có thể xử lý càng nhiều yêu cầu của người dùng càng tốt mà không gặp rủi ro quá tải. Như vâyh, mỗi luồng sẽ thực thi một chức năng được xác định, chẳng hạn như nhận hộp thư của mã định danh được truyền vào, void request_mail (string user_name).

Một ví dụ khác có thể là trình thu thập dữ liệu web, tải xuống các trang trên web. Bằng cách sử dụng đa luồng, develper sẽ đảm bảo rằng trình thu thập dữ liệu web đang sử dụng càng nhiều khả năng của phần cứng càng tốt để tải xuống và lập chỉ mục nhiều trang cùng một lúc.

Chỉ dựa trên hai ví dụ này, chúng ta có thể thấy độ phủ rộng của các chương trình trong đó đồng thời là rất nhiều. Với số lượng lõi CPU trong mỗi máy tính tăng theo năm, kỹ thuật lập trình đồng thời chắc chắn vẫn là một tài sản vô giá trong kho vũ khí của develper hiện đại.

Chia sẻ bài viết này:

  • Facebook
  • LinkedIn
  • Reddit
  • Telegram

Thích điều này:

Thích Đang tải…

Từ khóa » đa Luồng C++