之前在《C++20 多執行序間的同步點 barrier 與 latch》有簡單提到過 C++11 有 std::call_once()
這個函式(C++ Reference),不過好像從來沒有認真介紹過?這邊就來稍微補充一下吧~
std::call_once()
的基本目的,是讓開發者可以確保一個函式就算被多次呼叫、也只會被執行一次;而且這邊的重點,是就算在多執行序環境下,也可以做到。
他被定義在標準函式庫的 <mutex>
裡,在使用上需要搭配 std::once_flag
使用;而第二個參數則是一個可呼叫物件(例如函式)。下面是一個最簡單的範例:
#include <iostream> #include <mutex> void test() { std::cout << "hello~" << std::endl; } int main() { std::once_flag mFlag; std::call_once(mFlag, test); std::call_once(mFlag, test); }
在上面的程式碼中,主程式這邊有透過 call_once()
呼叫 test()
兩次,但是實際執行後,會發現他只有被執行一次。
這是因為在第一次執行後,mFlag
的狀態會被從未完成更改程已完成,所以第二次呼叫就會被跳過、不會被執行了;所以上面的程式在執行後,只會輸出一次「hello~
」。
而如果要執行的函示有其他參數的話,也可以直接透過 call_once()
傳遞進去。
#include <iostream> #include <mutex> void test(int val) { std::cout << "variable: " << val << std::endl; } int main() { std::once_flag mFlag; for(int i = 0; i < 3; ++ i) std::call_once(mFlag, test, i); }
上面的程式執行後,會出現「variable: 0
」,明確代表只有執行第一次的呼叫。
而在使用 call_once()
時可能要注意的,是如果 呼叫的函式有丟出例外的話,那所使用的 once_flag
就不會被修改成已完成的狀態,所以會交給使用同一個 once_flag
的其他呼叫去執行。
下面是一個範例:
#include <iostream> #include <mutex> void test(int val) { std::cout << "variable: " << val << " start" << std::endl; if (val == 0) throw std::runtime_error("==1"); std::cout << "variable: " << val << " end" << std::endl; } int main() { std::once_flag mFlag; for (int i = 0; i < 3; ++i) { try { std::call_once(mFlag, test, i); } catch (std::runtime_error e) { std::cerr << "error: " << e.what() << std::endl; } } }
這邊會透過迴圈、透過 call_once()
呼叫 test()
三次,傳入的參數分別是 0、1、2;而執行後輸出的結果會是:
variable: 0 start error: ==1 variable: 1 start variable: 1 end
可以看到,在呼叫 test(0)
的時候,由於會丟出例外,所以 test()
並不會跑完,所以 mFlag
並不會被修改。
而因為 mFlag
還是處於未完成的狀態,所以接下來的 test(1)
會被正常執行、並在結束後將 mFlag
修改為已完成。
最後在呼叫 test(2)
的時候,由於 mFlag
是已完成的狀態,所以就根本不會被執行了。
上面都是在只有主執行序的單一執行序的狀況下來解釋,但是其實在沒有多執行序的狀況下,其實要自己實作類似的管理會是滿簡單的,應該沒有必要特別去使用他。
不過在多執行序的狀況下,要實作這樣的控制會比較麻煩,使用 call_once()
的話應該還是會比較方便的。
下面就是一個比較簡單的多執行序範例:
#include <iostream> #include <mutex> #include <thread> std::once_flag mFlag; void test(int val) { std::cout << "variable: " << val << std::endl; } void once_test(int val) { std::call_once(mFlag, test, val); } int main() { std::jthread t1(once_test, 0); std::jthread t2(once_test, 1); }
透過這樣的方法,就可以比較方便地在多執行序的環境中,確保函式只會被執行一次了;這邊雖然也可以自己用 mutex lock 來實作,但是會相對繁瑣就是了。
而在個人來看,這個東西比較實用的狀況,應該還是在多執行序環境下,跑到一個階段之後需要執行僅有一個執行序要執行的工作的狀況。
比如說在之前《C++20 多執行序間的同步點 barrier 與 latch》中最後那個範例,如果改用 call_once()
的話,可以寫成:
#include <array> #include <iostream> #include <syncstream> #include <thread> #include <latch> #include <mutex> int main() { constexpr size_t numThread = 3; std::latch sync_point(numThread); std::once_flag flgOutput; std::array<std::jthread, numThread> vThreads; for (auto& t : vThreads) { t = std::jthread( [&sync_point, &flgOutput]() { std::osyncstream(std::cout) << "stage 1\n"; sync_point.arrive_and_wait(); std::call_once(flgOutput, []() {std::cout << "sync point\n"; }); std::osyncstream(std::cout) << "stage 2\n"; }); } }
在特定的狀況下,這樣的寫法會是比較簡單、而且在語意上相對明確的。
而且 call_once()
實際上還有一個特性,就是當同時使用同一個 once_flag
的話,那在不同執行序的 call_once()
會在同時結束,所以不需要再去做額外的同步!
#include <iostream> #include <array> #include <mutex> #include <thread> #include <syncstream> void test(int val) { std::cout << " > test from " << val << " started" << std::endl; std::this_thread::sleep_for(std::chrono::seconds(1)); std::cout << " > test from " << val << " end" << std::endl; } int main() { std::once_flag mFlag; std::array<std::jthread, 3> aThreads; size_t i = 0; for (auto& t : aThreads) { t = std::jthread( [&mFlag, idx=++i](){ std::osyncstream(std::cout) << idx << ": thread start" << std::endl; std::call_once(mFlag, test, idx); std::osyncstream(std::cout) << idx << ": thread end" << std::endl; }); } }
上面的程式在執行後的輸出會類似下面的狀況:
3: thread start 1: thread start > test from 3 started 2: thread start > test from 3 end 2: thread end 1: thread end 3: thread end
可以看到,雖然 test()
這個函式只有被第三個執行序執行,但是其他兩個執行序也會在那邊等,而不是直接結束。這樣設計的好處,就是不需要額外再去做同步了~