確保函式只會被呼叫一次:call_once

| | 0 Comments| 08:28
Categories:

之前在《C++20 多執行序間的同步點 barrier 與 latch》有簡單提到過 C++11std::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() 這個函式只有被第三個執行序執行,但是其他兩個執行序也會在那邊等,而不是直接結束。這樣設計的好處,就是不需要額外再去做同步了~

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *