Cập nhật và kết xuất trong các chủ đề riêng biệt


11

Tôi đang tạo một công cụ trò chơi 2D đơn giản và tôi muốn cập nhật và kết xuất các họa tiết trong các luồng khác nhau, để tìm hiểu cách thực hiện.

Tôi cần phải đồng bộ hóa luồng cập nhật và kết xuất. Hiện tại, tôi sử dụng hai lá cờ nguyên tử. Quy trình làm việc trông giống như:

Thread 1 -------------------------- Thread 2
Update obj ------------------------ wait for swap
Create queue ---------------------- render the queue
Wait for render ------------------- notify render done
Swap render queues ---------------- notify swap done

Trong thiết lập này, tôi giới hạn FPS của luồng kết xuất ở FPS của luồng cập nhật. Ngoài ra, tôi sử dụng sleep()để giới hạn cả kết xuất và cập nhật FPS của luồng thành 60, vì vậy hai chức năng chờ sẽ không phải chờ nhiều thời gian.

Vấn đề là:

Mức sử dụng CPU trung bình là khoảng 0,1%. Đôi khi, nó lên tới 25% (trong PC lõi tứ). Điều đó có nghĩa là một luồng đang chờ chuỗi khác vì hàm chờ là vòng lặp while có chức năng kiểm tra và thiết lập và vòng lặp while sẽ sử dụng tất cả tài nguyên CPU của bạn.

Câu hỏi đầu tiên của tôi là: có cách nào khác để đồng bộ hóa hai luồng không? Tôi nhận thấy rằng std::mutex::lockkhông sử dụng CPU trong khi nó đang chờ khóa tài nguyên để nó không phải là vòng lặp while. Làm thế nào nó hoạt động? Tôi không thể sử dụng std::mutexvì tôi sẽ cần khóa chúng trong một chuỗi và mở khóa trong một luồng khác.

Câu hỏi khác là; vì chương trình luôn chạy ở tốc độ 60 FPS, tại sao đôi khi mức sử dụng CPU của nó tăng vọt lên 25%, có nghĩa là một trong hai chờ đợi đang chờ đợi rất nhiều? (cả hai luồng đều bị giới hạn ở 60 khung hình / giây nên lý tưởng là chúng không cần nhiều sự đồng bộ hóa).

Chỉnh sửa: Cảm ơn tất cả các câu trả lời. Đầu tiên tôi muốn nói rằng tôi không bắt đầu một chủ đề mới mỗi khung hình để kết xuất. Tôi bắt đầu cả vòng lặp cập nhật và kết xuất lúc đầu. Tôi nghĩ rằng đa luồng có thể tiết kiệm thời gian: Tôi có các chức năng sau: FastAlg () và Alg (). Alg () là cả obj Cập nhật của tôi và kết xuất obj và Fastache () là "gửi hàng đợi kết xuất tới" trình kết xuất "" của tôi. Trong một chủ đề duy nhất:

Alg() //update 
FastAgl() 
Alg() //render

Trong hai chủ đề:

Alg() //update  while Alg() //render last frame
FastAlg() 

Vì vậy, có thể đa luồng có thể tiết kiệm cùng thời gian. (thực ra trong một ứng dụng toán học đơn giản, trong đó alg là một thuật toán dài, nhanh hơn một thuật toán nhanh hơn)

Tôi biết rằng giấc ngủ không phải là một ý tưởng tốt, mặc dù tôi không bao giờ có vấn đề. Điều này sẽ tốt hơn?

While(true) 
{
   If(timer.gettimefromlastcall() >= 1/fps)
   Do_update()
}

Nhưng đây sẽ là một vòng lặp vô hạn trong đó sẽ sử dụng tất cả CPU. Tôi có thể sử dụng chế độ ngủ (số <15) để hạn chế việc sử dụng không? Theo cách này, nó sẽ chạy ở tốc độ 100 khung hình / giây và chức năng cập nhật sẽ được gọi chỉ 60 lần mỗi giây.

Để đồng bộ hóa hai luồng, tôi sẽ sử dụng Waitforsingleobject với createSemaphore để tôi có thể khóa và mở khóa trong các luồng khác nhau (xóa trắng bằng vòng lặp while), phải không?


5
"Đừng nói đa luồng của tôi là vô ích trong trường hợp này, tôi chỉ muốn học cách làm" - trong trường hợp đó bạn nên học mọi thứ đúng cách, đó là (a) không sử dụng ngủ () để kiểm soát khung hình hiếm gặp , không bao giờ và (b) tránh thiết kế theo từng thành phần và tránh chạy bước, thay vào đó phân chia công việc trong các tác vụ và xử lý các tác vụ từ hàng đợi công việc.
Damon

1
@Damon (a) ngủ () có thể được sử dụng như một cơ chế tốc độ khung hình và trên thực tế khá phổ biến, mặc dù tôi phải đồng ý rằng có nhiều lựa chọn tốt hơn. (b) Người dùng ở đây muốn tách cả cập nhật và kết xuất thành hai luồng khác nhau. Đây là một sự tách biệt bình thường trong một công cụ trò chơi và không phải là "luồng trên mỗi thành phần". Nó mang lại lợi thế rõ ràng nhưng nó có thể mang lại vấn đề nếu thực hiện không chính xác.
Alexandre Desbiens

@AlphSprite: Thực tế là một cái gì đó là "phổ biến" không có nghĩa là nó không sai . Thậm chí không cần đi sâu vào bộ tính giờ khác nhau, độ chi tiết của giấc ngủ trên ít nhất một hệ điều hành máy tính để bàn phổ biến là đủ lý do, nếu không phải là không đáng tin cậy trên mỗi hệ thống tiêu dùng hiện có. Giải thích tại sao việc tách cập nhật và kết xuất thành hai luồng như mô tả là không khôn ngoan và gây ra nhiều rắc rối hơn giá trị của nó sẽ mất quá nhiều thời gian. Mục tiêu của OP được nêu là tìm hiểu cách thức thực hiện , cần tìm hiểu cách thực hiện chính xác . Rất nhiều bài viết về thiết kế động cơ MT hiện đại xung quanh.
Damon

@Damon Khi tôi nói nó phổ biến, hoặc phổ biến, tôi không có ý nói nó đúng. Tôi chỉ có nghĩa là nó đã được sử dụng bởi nhiều người. "... Mặc dù tôi phải đồng ý rằng có nhiều lựa chọn tốt hơn" có nghĩa là nó thực sự không phải là một cách tốt để đồng bộ hóa thời gian. Xin lỗi vì sự hiểu lầm.
Alexandre Desbiens

@AlphSprite: Không phải lo lắng :-) Thế giới đầy những thứ mà nhiều người làm (và không phải lúc nào cũng vì một lý do chính đáng), nhưng khi bắt đầu học, người ta vẫn nên cố gắng tránh những điều sai trái rõ ràng nhất.
Damon

Câu trả lời:


25

Đối với một công cụ 2D đơn giản với các họa tiết, cách tiếp cận đơn luồng là hoàn toàn tốt. Nhưng vì bạn muốn học cách làm đa luồng, bạn nên học cách làm đúng.

Đừng

  • Sử dụng 2 luồng chạy nhiều bước hoặc ít hơn, thực hiện một hành vi đơn luồng với một số luồng. Điều này có cùng mức độ song song (không) nhưng thêm chi phí cho chuyển đổi ngữ cảnh và đồng bộ hóa. Thêm vào đó, logic khó hơn để mò mẫm.
  • Sử dụng sleepđể kiểm soát tốc độ khung hình. Không bao giờ. Nếu ai đó nói với bạn, hãy đánh họ.
    Đầu tiên, không phải tất cả các màn hình chạy ở 60Hz. Thứ hai, hai bộ định thời tích tắc ở cùng một tốc độ chạy cạnh nhau cuối cùng sẽ luôn không đồng bộ (thả hai quả bóng bàn trên một bàn từ cùng một độ cao và lắng nghe). Thứ ba, sleepdo thiết kế không chính xác cũng không đáng tin cậy. Độ chi tiết có thể tệ đến 15,6ms (trên thực tế, mặc định trên Windows [1] ) và khung hình chỉ 16,6ms ở 60fps, chỉ để lại 1ms cho mọi thứ khác. Ngoài ra, thật khó để có được 16,6 thành bội số của 15,6 ...
    Ngoài ra, sleepđôi khi được phép (và đôi khi!) Chỉ trở lại sau 30 hoặc 50 hoặc 100 ms, hoặc thậm chí lâu hơn.
  • Sử dụng std::mutexđể thông báo một chủ đề khác. Đây không phải là những gì nó làm.
  • Giả sử rằng Trình quản lý tác vụ rất tốt trong việc cho bạn biết điều gì đang xảy ra, đặc biệt là đánh giá từ một số như "CPU 25%", có thể được sử dụng trong mã của bạn hoặc trong trình điều khiển usermode hoặc ở nơi khác.
  • Có một luồng cho mỗi thành phần cấp cao (tất nhiên có một số ngoại lệ).
  • Tạo chủ đề tại "thời gian ngẫu nhiên", ad hoc, mỗi nhiệm vụ. Việc tạo các chủ đề có thể tốn kém một cách đáng ngạc nhiên và chúng có thể mất một thời gian dài đáng ngạc nhiên trước khi chúng thực hiện những gì bạn đã nói với chúng (đặc biệt là nếu bạn đã tải rất nhiều DLL!).

Làm

  • Sử dụng đa luồng để mọi thứ chạy không đồng bộ nhiều nhất có thể. Tốc độ không phải là ý tưởng chính của luồng, nhưng thực hiện song song (vì vậy ngay cả khi chúng mất nhiều thời gian hơn, tổng của tất cả vẫn ít hơn).
  • Sử dụng đồng bộ dọc để giới hạn tốc độ khung hình. Đó là cách duy nhất đúng (và không thất bại) để làm điều đó. Nếu người dùng ghi đè bạn trong bảng điều khiển của trình điều khiển hiển thị ("tắt nguồn"), thì cũng vậy. Sau tất cả, đó là máy tính của anh ấy, không phải của bạn.
  • Nếu bạn cần "đánh dấu" một cái gì đó đều đặn, hãy sử dụng bộ hẹn giờ . Bộ hẹn giờ có lợi thế là có độ chính xác và độ tin cậy tốt hơn nhiều so với sleep[2] . Ngoài ra, một bộ đếm thời gian định kỳ chiếm thời gian chính xác (bao gồm cả thời gian trôi qua giữa) trong khi ngủ trong 16,6ms (hoặc 16,6ms trừ đi đo_time_elapsed) thì không.
  • Chạy các mô phỏng vật lý liên quan đến tích hợp số ở bước thời gian cố định (hoặc phương trình của bạn sẽ bùng nổ!), Nội suy đồ họa giữa các bước (đây có thể là một lý do cho một luồng cho mỗi thành phần riêng biệt, nhưng cũng có thể được thực hiện mà không có).
  • Sử dụng std::mutexđể chỉ có một luồng truy cập tài nguyên tại một thời điểm ("loại trừ lẫn nhau") và để tuân thủ ngữ nghĩa kỳ lạ của std::condition_variable.
  • Tránh có chủ đề cạnh tranh cho tài nguyên. Khóa càng ít càng cần thiết (nhưng không ít hơn!) Và chỉ giữ khóa miễn là hoàn toàn cần thiết.
  • Không chia sẻ dữ liệu chỉ đọc giữa các luồng (không có vấn đề về bộ đệm và không cần khóa), nhưng không sửa đổi đồng thời dữ liệu (cần đồng bộ hóa và giết bộ đệm). Điều đó bao gồm sửa đổi dữ liệu gần vị trí mà người khác có thể đọc.
  • Sử dụng std::condition_variableđể chặn một chủ đề khác cho đến khi một số điều kiện là đúng. Các ngữ nghĩa std::condition_variablevới thêm mutex đó được thừa nhận là khá kỳ lạ và xoắn (chủ yếu là vì lý do lịch sử được kế thừa từ các luồng POSIX), nhưng một biến điều kiện là nguyên thủy chính xác để sử dụng cho những gì bạn muốn.
    Trong trường hợp bạn thấy std::condition_variablequá kỳ lạ để có thể thoải mái với nó, bạn cũng có thể chỉ cần sử dụng một sự kiện Windows (chậm hơn một chút) hoặc, nếu bạn can đảm, hãy xây dựng sự kiện đơn giản của riêng bạn xung quanh NtKeyedEvents (liên quan đến những thứ cấp thấp đáng sợ). Khi bạn sử dụng DirectX, dù sao bạn cũng đã bị ràng buộc với Windows, do đó, việc mất tính di động không phải là vấn đề lớn.
  • Chia công việc thành các tác vụ có kích thước hợp lý được điều hành bởi nhóm luồng công nhân có kích thước cố định (không quá một lõi trên mỗi lõi, không tính các lõi siêu phân luồng). Hãy để hoàn thành nhiệm vụ enqueue nhiệm vụ phụ thuộc (miễn phí, đồng bộ hóa tự động). Thực hiện các tác vụ có ít nhất vài trăm thao tác không tầm thường mỗi (hoặc một thao tác chặn chiều dài như đọc đĩa). Thích truy cập bộ nhớ cache.
  • Tạo tất cả các chủ đề khi bắt đầu chương trình.
  • Tận dụng các chức năng không đồng bộ mà HĐH hoặc API đồ họa cung cấp để có sự song song tốt hơn / bổ sung, không chỉ ở cấp độ chương trình mà còn trên phần cứng (nghĩ chuyển PCIe, song song CPU-GPU, DMA đĩa, v.v.).
  • 10.000 điều khác mà tôi đã quên đề cập đến.


[1] Có, bạn có thể đặt tốc độ của trình lập lịch xuống còn 1ms, nhưng điều này được tán thành vì nó gây ra nhiều chuyển đổi ngữ cảnh hơn và tiêu thụ nhiều năng lượng hơn (trong một thế giới nơi ngày càng có nhiều thiết bị là thiết bị di động). Nó cũng không phải là một giải pháp vì nó vẫn không làm cho giấc ngủ trở nên đáng tin cậy hơn.
[2] Một bộ đếm thời gian sẽ tăng mức độ ưu tiên của luồng, điều này sẽ cho phép nó làm gián đoạn một lượng tử khác giữa mức độ ưu tiên bằng nhau và được lên lịch trước, đó là hành vi gần như RT. Nó tất nhiên không phải là RT thực sự, nhưng nó đến rất gần. Thức dậy từ giấc ngủ chỉ có nghĩa là chủ đề đã sẵn sàng để được lên lịch vào một lúc nào đó, bất cứ khi nào có thể.


Bạn có thể giải thích lý do tại sao bạn không nên "Có một luồng cho mỗi thành phần cấp cao" không? Bạn có nghĩa là người ta không nên trộn lẫn vật lý và âm thanh thành hai luồng riêng biệt? Tôi không thấy bất kỳ lý do để không làm như vậy.
Elviss Strazdins

3

Tôi không chắc chắn những gì bạn muốn đạt được bằng cách giới hạn FPS của Cập nhật và Kết xuất cả hai đến 60. Nếu bạn giới hạn chúng ở cùng một giá trị, bạn có thể chỉ cần đặt chúng vào cùng một chuỗi.

Mục tiêu khi phân tách Cập nhật và Kết xuất trong các luồng khác nhau là để cả hai "gần như" độc lập với nhau, để GPU có thể kết xuất 500 FPS và logic Cập nhật vẫn ở mức 60 FPS. Bạn không đạt được hiệu suất rất cao khi làm như vậy.

Nhưng bạn nói rằng bạn chỉ muốn biết làm thế nào nó hoạt động, và nó ổn. Trong C ++, mutex là một đối tượng đặc biệt được sử dụng để khóa quyền truy cập vào một số tài nguyên nhất định cho các luồng khác. Nói cách khác, bạn sử dụng một mutex để làm cho dữ liệu hợp lý chỉ có thể truy cập được bằng một luồng tại thời điểm đó. Để làm như vậy, nó khá đơn giản:

std::mutex mutex;
mutex.lock();
// Do sensible stuff here...
mutex.unlock();

Nguồn: http://en.cppreference.com/w/cpp/thread/mutex

EDIT : Hãy chắc chắn rằng mutex của bạn là lớp hoặc toàn tệp, như trong liên kết được cung cấp, nếu không, mỗi luồng sẽ tạo ra mutex riêng của nó và bạn sẽ không đạt được bất cứ điều gì.

Chuỗi đầu tiên để khóa mutex sẽ có quyền truy cập vào mã bên trong. Nếu một luồng thứ hai cố gắng gọi hàm lock (), nó sẽ chặn cho đến khi luồng đầu tiên mở khóa. Vì vậy, một mutex là một hàm blocing, không giống như một vòng lặp while. Chức năng chặn sẽ không gây căng thẳng cho CPU.


Và khối hoạt động như thế nào?
Liuka

Khi luồng thứ hai sẽ gọi lock (), nó sẽ kiên nhẫn chờ đợi chuỗi đầu tiên mở khóa mutex và sẽ tiếp tục trên dòng tiếp theo sau (trong ví dụ này là các công cụ hợp lý). EDIT: Chủ đề thứ hai sau đó sẽ khóa mutex cho chính nó.
Alexandre Desbiens


1
Sử dụng std::lock_guardhoặc tương tự, không .lock()/ .unlock(). RAII không chỉ dành cho quản lý bộ nhớ!
bcrist
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.