C++ 併發編程(從C++11到C++17)

// 07_mutex_lock.cpp

static const int MAX = 10e8;
static double sum = 0;

static mutex exclusive;

void concurrent_worker(int min, int max) {
  for (int i = min; i <= max; i++) {
    exclusive.lock(); // ①
    sum += sqrt(i);
    exclusive.unlock(); // ②
  }
}

void concurrent_task(int min, int max) {
  auto start_time = chrono::steady_clock::now();

  unsigned concurrent_count = thread::hardware_concurrency();
  cout << "hardware_concurrency: " << concurrent_count << endl;
  vector<thread> threads;
  min = 0;
  sum = 0;
  for (int t = 0; t < concurrent_count; t++) {
    int range = max / concurrent_count * (t + 1);
    threads.push_back(thread(concurrent_worker, min, range)); // ③
    min = range + 1;
  }
  for (int i = 0; i < threads.size(); i++) {
    threads[i].join();
  }

  auto end_time = chrono::steady_clock::now();
  auto ms = chrono::duration_cast<chrono::milliseconds>(end_time - start_time).count();
  cout << "Concurrent task finish, " << ms << " ms consumed, Result: " << sum << endl;
}

這裏只有三個地方需要關注:

  1. 在訪問共享數據之前加鎖
  2. 訪問完成之後解鎖
  3. 在多線程中使用帶鎖的版本

執行之後結果輸出如下:

hardware_concurrency: 16
Concurrent task finish, 74232 ms consumed, Result: 2.10819e+13

這下結果是對了,但是我們卻發現這個版本比原先單線程的版本性能還要差很多。這是爲什麼?

這是因爲加鎖和解鎖是有代價的,這裏計算最耗時的地方在鎖裏面,每次只能有一個線程串行執行,相比於單線程模型,它不但是串行的,還增加了鎖的負擔,因此就更慢了。

這就是爲什麼前面說多線程系統會增加系統的複雜度,而且並非多線程系統一定就有更好的性能。

不過,對於這裏的問題是可以改進的。我們仔細思考一下:我們劃分給每個線程的數據其實是獨立的,對於數據的處理是耗時的,但其實這部分邏輯每個線程可以單獨處理,沒必要加鎖。只有在最後彙總數據的時候進行一次鎖保護就可以了。

於是我們改造concurrent_worker,像下面這樣:

// 08_improved_mutex_lock.cpp

void concurrent_worker(int min, int max) {
  double tmp_sum = 0;
  for (int i = min; i <= max; i++) {
    tmp_sum += sqrt(i); // ①
  }
  exclusive.lock(); // ②
  sum += tmp_sum;
  exclusive.unlock();
}

這段代碼的改變在於兩處:

  1. 通過一個局部變量保存當前線程的處理結果
  2. 在彙總總結過的時候進行鎖保護

運行一下改進後的程序,其結果輸出如下:

hardware_concurrency: 16
Concurrent task finish, 451 ms consumed, Result: 2.10819e+13

可以看到,性能一下就提升了好多倍。我們終於體驗到多線程帶來的好處了。

我們用鎖的粒度(granularity)來描述鎖的範圍。細粒度(fine-grained)是指鎖保護較小的範圍,粗粒度(coarse-grained)是指鎖保護較大的範圍。出於性能的考慮,我們應該保證鎖的粒度儘可能的細。並且,不應該在獲取鎖的範圍內執行耗時的操作,例如執行IO。如果是耗時的運算,也應該儘可能的移到鎖的外面。

In general, a lock should be held for only the minimum possible time needed to perform the required operations.

–《C++ Concurrency in Action》

死鎖

死鎖是併發系統很常見的一類問題。

死鎖是指:兩個或以上的運算單元,每一方都在等待其他方釋放資源,但是所有方都不願意釋放資源。結果是沒有任何一方能繼續推進下去,於是整個系統無法再繼續運轉。

死鎖在現實中也很常見,例如:兩個孩子分別拿着玩具的一半然後哭着要從對方手裏得到另外一半玩具,但是誰都不肯讓步。

在成年人的世界裏也會發生類似的情況,例如下面這個交通狀況:

下面我們來看一個編程示例。

現在假設我們在開發一個銀行的系統,這個系統包含了轉賬的功能。

首先我們創建一個Account類來描述銀行賬號。由於這僅僅是一個演示使用的代碼,所以我們希望代碼足夠的簡單。Account類僅僅包含名稱和金額兩個字段。

另外,爲了支持併發,這個類包含了一個mutex對象,用來保護賬號金額,在讀寫賬號金額時需要先加鎖保護。

// 09_deadlock_bank_transfer.cpp

class Account {
public:
  Account(string name, double money): mName(name), mMoney(money) {};

public:
  void changeMoney(double amount) {
    mMoney += amount;
  }
  string getName() {
    return mName;
  }
  double getMoney() {
    return mMoney;
  }
  mutex* getLock() {
    return &mMoneyLock;
  }

private:
  string mName;
  double mMoney;
  mutex mMoneyLock;
};

Account類很簡單,我想就不用多做說明了。

接下來,我們再創建一個描述銀行的Bank類。

// 09_deadlock_bank_transfer.cpp

class Bank {
public:
  void addAccount(Account* account) {
    mAccounts.insert(account);
  }

  bool transferMoney(Account* accountA, Account* accountB, double amount) {
    lock_guard guardA(*accountA->getLock()); // ①
    lock_guard guardB(*accountB->getLock());

    if (amount > accountA->getMoney()) { // ②
      return false;
    }

    accountA->changeMoney(-amount); // ③
    accountB->changeMoney(amount);
    return true;
  }

  double totalMoney() const {
    double sum = 0;
    for (auto a : mAccounts) {
      sum += a->getMoney();
    }
    return sum;
  }

private:
  set<Account*> mAccounts;
};

銀行類中記錄了所有的賬號,並且提供了一個方法用來查詢整個銀行的總金額。

這其中,我們最主要要關注轉賬的實現:transferMoney。該方法的幾個關鍵點如下:

  1. 爲了保證線程安全,在修改每個賬號之前,需要獲取相應的鎖。
  2. 判斷轉出賬戶金額是否足夠,如果不夠此次轉賬失敗。
  3. 進行轉賬。

有了銀行和賬戶結構之後就可以開發轉賬系統了,同樣的,由於是爲了演示所用,我們的轉賬系統也會儘可能的簡單:

// 09_deadlock_bank_transfer.cpp

void randomTransfer(Bank* bank, Account* accountA, Account* accountB) {
  while(true) {
    double randomMoney = ((double)rand() / RAND_MAX) * 100;
    if (bank->transferMoney(accountA, accountB, randomMoney)) {
      cout << "Transfer " << randomMoney << " from " << accountA->getName()
           << " to " << accountB->getName()
           << ", Bank totalMoney: " << bank->totalMoney() << endl;
    } else {
      cout << "Transfer failed, "
           << accountA->getName() << " has only $" << accountA->getMoney() << ", but "
           << randomMoney << " required" << endl;
    }
  }
}

這裏每次生成一個隨機數,然後通過銀行進行轉賬。

最後我們在main函數中創建兩個線程,互相在兩個賬號之間來回轉賬:

// 09_deadlock_bank_transfer.cpp

int main() {
  Account a("Paul", 100);
  Account b("Moira", 100);

  Bank aBank;
  aBank.addAccount(&a);
  aBank.addAccount(&b);

  thread t1(randomTransfer, &aBank, &a, &b);
  thread t2(randomTransfer, &aBank, &b, &a);

  t1.join();
  t2.join();

  return 0;
}

至此,我們的銀行轉賬系統就開發完成了。然後編譯並運行,其結果可能像下面這樣:

...
Transfer 13.2901 from Paul to Moira, Bank totalMoney: 20042.6259 from Moira to Paul, Bank totalMoney: 200
Transfer failed, Moira has only $34.7581, but 66.3208 required
Transfer failed, Moira has only $34.7581, but 
Transfer 93.191 from 53.9176 required
Transfer 60.6146 from Moira to Paul, Bank totalMoney: 200
Transfer 49.7304 from Moira to Paul, Bank totalMoney: 200Paul to Moira, Bank totalMoney: 
Transfer failed, Moira has only $17.6041, but 18.1186 required
Transfer failed, Moira has only $17.6041, but 18.893 required
Transfer failed, Moira has only $17.6041, but 34.7078 required
Transfer failed, Moira has only $17.6041, but 33.9569 required
Transfer 12.7899 from 200
Moira to Paul, Bank totalMoney: 200
Transfer failed, Moira has only $63.9373, but 80.9038 required
Transfer 50.933 from Moira to Paul, Bank totalMoney: 200
Transfer failed, Moira has only $13.0043, but 30.2056 required
Transfer failed, Moira has only $Transfer 59.123 from Paul to Moira, Bank totalMoney: 200
Transfer 29.0486 from Paul to Moira, Bank totalMoney: 20013.0043, but 64.7307 required

如果你運行了這個程序,你會發現很快它就卡住不動了。爲什麼?

因爲發生了死鎖。

我們仔細思考一下這兩個線程的邏輯:這兩個線程可能會同時獲取其中一個賬號的鎖,然後又想獲取另外一個賬號的鎖,此時就發生了死鎖。如下圖所示:

當然,發生死鎖的原因遠不止上面這一種情況。如果兩個線程互相join就可能發生死鎖。還有在一個線程中對一個不可重入的互斥體(例如mutex而非recursive_mutex)多次加鎖也會死鎖。

你可能會覺得,我可不會這麼傻,寫出這樣的代碼。但實際上,很多時候是由於代碼的深層次嵌套導致了死鎖的發生,由於調用關係的複雜導致發現這類問題並不容易。

如果仔細看一下上面的輸出,我們會發現還有另外一個問題:這裏的輸出是亂的。兩個線程的輸出混雜在一起了。究其原因也很容易理解:兩個線程可能會同時輸出,沒有做好隔離。

下面我們就來逐步解決上面的問題。

對於輸出混亂的問題很好解決,專門用一把鎖來保護輸出邏輯即可:

// 10_improved_bank_transfer.cpp

mutex sCoutLock;
void randomTransfer(Bank* bank, Account* accountA, Account* accountB) {
  while(true) {
    double randomMoney = ((double)rand() / RAND_MAX) * 100;
    if (bank->transferMoney(accountA, accountB, randomMoney)) {
      sCoutLock.lock();
      cout << "Transfer " << randomMoney << " from " << accountA->getName()
          << " to " << accountB->getName()
          << ", Bank totalMoney: " << bank->totalMoney() << endl;
      sCoutLock.unlock();
    } else {
      sCoutLock.lock();
      cout << "Transfer failed, "
           << accountA->getName() << " has only " << accountA->getMoney() << ", but "
           << randomMoney << " required" << endl;
      sCoutLock.unlock();
    }
  }
}

請思考一下兩處lockunlock調用,並考慮爲什麼不在while(true)下面寫一次整體的加鎖和解鎖。

通用鎖定算法

  • 主要API
API C++標準 說明
lock C++11 鎖定指定的互斥體,若任何一個不可用則阻塞
try_lock C++11 試圖通過重複調用 try_lock 獲得互斥體的所有權

要避免死鎖,需要仔細的思考和設計業務邏輯。

有一個比較簡單的原則可以避免死鎖,即:對所有的鎖進行排序,每次一定要按照順序來獲取鎖,不允許亂序。例如:要獲取某個玩具,一定要先拿到鎖A,再拿到鎖B,才能玩玩具。這樣就不會死鎖了。

這個原則雖然簡單,但卻不容易遵守。因爲數據常常是分散在很多地方的。

不過好消息是,C++ 11標準中爲我們提供了一些工具來避免因爲多把鎖而導致的死鎖。我們只要直接調用這些接口就可以了。這個就是上面提到的兩個函數。它們都支持傳入多個Lockable對象。

接下來我們用它來改造之前死鎖的轉賬系統:

// 10_improved_bank_transfer.cpp

bool transferMoney(Account* accountA, Account* accountB, double amount) {
  lock(*accountA->getLock(), *accountB->getLock());    // ①
  lock_guard lockA(*accountA->getLock(), adopt_lock);  // ②
  lock_guard lockB(*accountB->getLock(), adopt_lock);  // ③

  if (amount > accountA->getMoney()) {
    return false;
  }

  accountA->changeMoney(-amount);
  accountB->changeMoney(amount);
  return true;
}

這裏只改動了3行代碼。

  1. 這裏通過lock函數來獲取兩把鎖,標準庫的實現會保證不會發生死鎖。
  2. lock_guard在下面我們還會詳細介紹。這裏只要知道它會在自身對象生命週期的範圍內鎖定互斥體即可。創建lock_guard的目的是爲了在transferMoney結束的時候釋放鎖,lockB也是一樣。但需要注意的是,這裏傳遞了 adopt_lock表示:現在是已經獲取到互斥體了的狀態了,不用再次加鎖(如果不加adopt_lock就是二次鎖定了)。

運行一下這個改造後的程序,其輸出如下所示:

...
Transfer failed, Paul has only $1.76243, but 17.5974 required
Transfer failed, Paul has only $1.76243, but 59.2104 required
Transfer failed, Paul has only $1.76243, but 49.6379 required
Transfer failed, Paul has only $1.76243, but 63.6373 required
Transfer failed, Paul has only $1.76243, but 51.8742 required
Transfer failed, Paul has only $1.76243, but 50.0081 required
Transfer failed, Paul has only $1.76243, but 86.1041 required
Transfer failed, Paul has only $1.76243, but 51.3278 required
Transfer failed, Paul has only $1.76243, but 66.5754 required
Transfer failed, Paul has only $1.76243, but 32.1867 required
Transfer failed, Paul has only $1.76243, but 62.0039 required
Transfer failed, Paul has only $1.76243, but 98.7819 required
Transfer failed, Paul has only $1.76243, but 27.046 required
Transfer failed, Paul has only $1.76243, but 62.9155 required
Transfer 98.8478 from Moira to Paul, Bank totalMoney: 200
Transfer 80.0722 from Moira to Paul, Bank totalMoney: 200
Transfer 73.7035 from Moira to Paul, Bank totalMoney: 200
Transfer 34.4476 from Moira to Paul, Bank totalMoney: 200
Transfer failed, Moira has only $10.0142, but 61.3033 required
Transfer failed, Moira has only $10.0142, but 24.5595 required
...

現在這個轉賬程序會一直運行下去,不會再死鎖了。輸出也是正常的了。

通用互斥管理

  • 主要API
API C++標準 說明
lock_guard C++11 實現嚴格基於作用域的互斥體所有權包裝器
unique_lock C++11 實現可移動的互斥體所有權包裝器
shared_lock C++14 實現可移動的共享互斥體所有權封裝器
scoped_lock C++17 用於多個互斥體的免死鎖 RAII 封裝器
鎖定策略 C++標準 說明
defer_lock C++11 類型爲 defer_lock_t,不獲得互斥的所有權
try_to_lock C++11 類型爲try_to_lock_t,嘗試獲得互斥的所有權而不阻塞
adopt_lock C++11 類型爲adopt_lock_t,假設調用方已擁有互斥的所有權

互斥體(mutex相關類)提供了對於資源的保護功能,但是手動的鎖定(調用lock或者try_lock)和解鎖(調用unlock)互斥體是要耗費比較大的精力的,我們需要精心考慮和設計代碼才行。因爲我們需要保證,在任何情況下,解鎖要和加鎖配對,因爲假設出現一條路徑導致獲取鎖之後沒有正常釋放,就會影響整個系統。如果考慮方法還可以會拋出異常,這樣的代碼寫起來會很費勁。

鑑於這個原因,標準庫就提供了上面的這些API。它們都使用了叫做RAII的編程技巧,來簡化我們手動加鎖和解鎖的“體力活”。

請看下面的例子:

// https://en.cppreference.com/w/cpp/thread/lock_guard

#include <thread>
#include <mutex>
#include <iostream>
 
int g_i = 0;
std::mutex g_i_mutex;  // ①
 
void safe_increment()
{
  std::lock_guard<std::mutex> lock(g_i_mutex);  // ②
  ++g_i;

  std::cout << std::this_thread::get_id() << ": " << g_i << '\n';
  // ③
}
 
int main()
{
  std::cout << "main: " << g_i << '\n';
 
  std::thread t1(safe_increment); // ④
  std::thread t2(safe_increment);
 
  t1.join();
  t2.join();
 
  std::cout << "main: " << g_i << '\n';
}

這段代碼中:

  1. 全局的互斥體g_i_mutex用來保護全局變量g_i
  2. 這是一個設計爲可以被多線程環境使用的方法。因此需要通過互斥體來進行保護。這裏沒有調用lock方法,而是直接使用lock_guard來鎖定互斥體。
  3. 在方法結束的時候,局部變量std::lock_guard<std::mutex> lock會被銷燬,它對互斥體的鎖定也就解除了。
  4. 在多個線程中使用這個方法。

RAII

上面的幾個類(lock_guardunique_lockshared_lockscoped_lock)都使用了一個叫做RAII的編程技巧。

RAII全稱是Resource Acquisition Is Initialization,直譯過來就是:資源獲取即初始化。

RAII是一種C++編程技術,它將必須在使用前請求的資源(例如:分配的堆內存、執行線程、打開的套接字、打開的文件、鎖定的互斥體、磁盤空間、數據庫連接等——任何存在受限供給中的事物)的生命週期與一個對象的生存週期相綁定。 RAII保證資源可用於任何會訪問該對象的函數。它亦保證所有資源在其控制對象的生存期結束時,以獲取順序的逆序釋放。類似地,若資源獲取失敗(構造函數以異常退出),則爲已構造完成的對象和基類子對象所獲取的所有資源,會以初始化順序的逆序釋放。這有效地利用了語言特性以消除內存泄漏並保證異常安全。

RAII 可總結如下:

  • 將每個資源封裝入一個類,其中:
    • 構造函數請求資源,並建立所有類不變式,或在它無法完成時拋出異常,
    • 析構函數釋放資源並決不拋出異常;
  • 始終經由 RAII 類的實例使用滿足要求的資源,該資源
    • 自身擁有自動存儲期或臨時生存期,或
    • 具有與自動或臨時對象的生存期綁定的生存期

回想一下上文中的transferMoney方法中的三行代碼:

lock(*accountA->getLock(), *accountB->getLock());
lock_guard lockA(*accountA->getLock(), adopt_lock);
lock_guard lockB(*accountB->getLock(), adopt_lock);

如果使用unique_lock這三行代碼還有一種等價的寫法:

unique_lock lockA(*accountA->getLock(), defer_lock);
unique_lock lockB(*accountB->getLock(), defer_lock);
lock(*accountA->getLock(), *accountB->getLock());

請注意這裏lock方法的調用位置。這裏先定義unique_lock指定了defer_lock,因此實際沒有鎖定互斥體,而是到第三行才進行鎖定。

最後,藉助scoped_lock,我們可以將三行代碼合成一行,這種寫法也是等價的。

scoped_lock lockAll(*accountA->getLock(), *accountB->getLock());

scoped_lock會在其生命週期範圍內鎖定互斥體,銷燬的時候解鎖。同時,它可以鎖定多個互斥體,並且避免死鎖。

目前,只還有shared_lock我們沒有提到。它與其他幾個類的區別在於:它是以共享的方式鎖定互斥體。

條件變量

API C++標準 說明
condition_variable C++ 11 提供與 std::unique_lock 關聯的條件變量
condition_variable_any C++ 11 提供與任何鎖類型關聯的條件變量
notify_all_at_thread_exit C++ 11 安排到在此線程完全結束時對 notify_all 的調用
cv_status C++ 11 列出條件變量上定時等待的可能結果

至此,我們還有一個地方可以改進。那就是:轉賬金額不足的時候,程序直接返回了false。這很難說是一個好的策略。因爲,即便雖然當前賬號金額不足以轉賬,但只要別的賬號又轉賬進來之後,當前這個轉賬操作也許就可以繼續執行了。

這在很多業務中是很常見的一個需求:每一次操作都要正確執行,如果條件不滿足就停下來等待,直到條件滿足之後再繼續。而不是直接返回。

條件變量提供了一個可以讓多個線程間同步協作的功能。這對於生產者-消費者模型很有意義。在這個模型下:

  • 生產者和消費者共享一個工作區。這個區間的大小是有限的。
  • 生產者總是產生數據放入工作區中,當工作區滿了。它就停下來等消費者消費一部分數據,然後繼續工作。
  • 消費者總是從工作區中拿出數據使用。當工作區中的數據全部被消費空了之後,它也會停下來等待生產者往工作區中放入新的數據。

從上面可以看到,無論是生產者還是消費者,當它們工作的條件不滿足時,它們並不是直接報錯返回,而是停下來等待,直到條件滿足。

下面我們就藉助於條件變量,再次改造之前的銀行轉賬系統。

這個改造主要在於賬號類。我們重點是要調整changeMoney方法。

// 11_bank_transfer_wait_notify.cpp

class Account {
public:
  Account(string name, double money): mName(name), mMoney(money) {};

public:
  void changeMoney(double amount) {
    unique_lock lock(mMoneyLock); // ②
    mConditionVar.wait(lock, [this, amount] { // ③
      return mMoney + amount > 0; // ④
    });
    mMoney += amount;
    mConditionVar.notify_all(); // ⑤
  }

  string getName() {
    return mName;
  }

  double getMoney() {
    return mMoney;
  }

private:
  string mName;
  double mMoney;
  mutex mMoneyLock;
  condition_variable mConditionVar; // ①
};

這幾處改動說明如下:

  1. 這裏聲明瞭一個條件變量,用來在多個線程之間協作。
  2. 這裏使用的是unique_lock,這是爲了與條件變量相配合。因爲條件變量會解鎖和重新鎖定互斥體。
  3. 這裏是比較重要的一個地方:通過條件變量進行等待。此時:會通過後面的lambda表達式判斷條件是否滿足。如果滿足則繼續;如果不滿足,則此處會解鎖互斥體,並讓當前線程等待解鎖這一點非常重要,因爲只有這樣,才能讓其他線程獲取互斥體。
  4. 這裏是條件變量等待的條件。如果你不熟悉lambda表達式,請自行網上學習,或者閱讀我之前寫的文章
  5. 此處也很重要。當金額發生變動之後,我們需要通知所有在條件變量上等待的其他線程。此時所有調用wait線程都會再次喚醒,然後嘗試獲取鎖(當然,只有一個能獲取到)並再次判斷條件是否滿足。除了notify_all還有notify_one,它只通知一個等待的線程。waitnotify就構成了線程間互相協作的工具。

請注意:waitnotify_all雖然是寫在一個函數中的,但是在運行時它們是在多線程環境中執行的,因此對於這段代碼,需要能夠從不同線程的角度去思考代碼的邏輯。這也是開發併發系統比較難的地方。

有了上面的改動之後,銀行的轉賬方法實現起來就很簡單了,不用再考慮數據保護的問題了:

// 11_bank_transfer_wait_notify.cpp

void Bank::transferMoney(Account* accountA, Account* accountB, double amount) {
    accountA->changeMoney(-amount);
    accountB->changeMoney(amount);
}

當然,轉賬邏輯也會變得簡單,不用再管轉賬失敗的情況發生。

// 11_bank_transfer_wait_notify.cpp

mutex sCoutLock;
void randomTransfer(Bank* bank, Account* accountA, Account* accountB) {
  while(true) {
    double randomMoney = ((double)rand() / RAND_MAX) * 100;
    {
      lock_guard guard(sCoutLock);
      cout << "Try to Transfer " << randomMoney
           << " from " << accountA->getName() << "(" << accountA->getMoney()
           << ") to " << accountB->getName() << "(" << accountB->getMoney()
           << "), Bank totalMoney: " << bank->totalMoney() << endl;
    }
    bank->transferMoney(accountA, accountB, randomMoney);
  }
}

修改完之後的程序運行輸出如下:

...
Try to Transfer 13.72 from Moira(10.9287) to Paul(189.071), Bank totalMoney: 200
Try to Transfer 28.6579 from Paul(189.071) to Moira(10.9287), Bank totalMoney: 200
Try to Transfer 91.8049 from Paul(160.413) to Moira(39.5866), Bank totalMoney: 200
Try to Transfer 5.56383 from Paul(82.3285) to Moira(117.672), Bank totalMoney: 200
Try to Transfer 11.3594 from Paul(76.7646) to Moira(123.235), Bank totalMoney: 200
Try to Transfer 16.9557 from Paul(65.4053) to Moira(134.595), Bank totalMoney: 200
Try to Transfer 74.998 from Paul(48.4495) to Moira(151.55), Bank totalMoney: 200
Try to Transfer 65.3005 from Moira(151.55) to Paul(48.4495), Bank totalMoney: 200
Try to Transfer 90.6084 from Moira(86.25) to Paul(113.75), Bank totalMoney: 125.002
Try to Transfer 99.6425 from Moira(70.6395) to Paul(129.36), Bank totalMoney: 200
Try to Transfer 55.2091 from Paul(129.36) to Moira(70.6395), Bank totalMoney: 200
Try to Transfer 92.259 from Paul(74.1513) to Moira(125.849), Bank totalMoney: 200
...

這下比之前都要好了。

但是細心的讀者會發現,Bank totalMoney的輸出有時候是200,有時候不是。但不管怎樣,即便這一次不是,下一次又是了。關於這一點,請讀者自行思考一下爲什麼,以及如何改進。

future

API C++標準 說明
async C++11 異步運行一個函數,並返回保有其結果的std::future
future C++11 等待被異步設置的值
packaged_task C++11 打包一個函數,存儲其返回值以進行異步獲取
promise C++11 存儲一個值以進行異步獲取
shared_future C++11 等待被異步設置的值(可能爲其他 future 所引用)

這一小節中,我們來熟悉更多的可以在併發環境中使用的工具,它們都位於<future>頭文件中。

async

很多語言都提供了異步的機制。異步使得耗時的操作不影響當前主線程的執行流。

在C++11中,async便是完成這樣的功能的。下面是一個代碼示例:

// 12_async_task.cpp

static const int MAX = 10e8;
static double sum = 0;

void worker(int min, int max) {
  for (int i = min; i <= max; i++) {
    sum += sqrt(i);
  }
}

int main() {
  sum = 0;
  auto f1 = async(worker, 0, MAX);
  cout << "Async task triggered" << endl;
  f1.wait();
  cout << "Async task finish, result: " << sum << endl << endl;
}

這仍然是我們之前熟悉的例子。這裏有兩個地方需要說明:

  1. 這裏以異步的方式啓動了任務。它會返回一個future對象。future用來存儲異步任務的執行結果,關於future我們在後面packaged_task的例子中再詳細說明。在這個例子中我們僅僅用它來等待任務執行完成。
  2. 此處是等待異步任務執行完成。

需要注意的是,默認情況下,async是啓動一個新的線程,還是以同步的方式(不啓動新的線程)運行任務,這一點標準是沒有指定的,由具體的編譯器決定。如果希望一定要以新的線程來異步執行任務,可以通過launch::async來明確說明。launch中有兩個常量:

  • async:運行新線程,以異步執行任務。
  • deferred:調用方線程上第一次請求其結果時才執行任務,即惰性求值。

除了通過函數來指定異步任務,還可以lambda表達式的方式來指定。如下所示:

// 12_async_task.cpp

int main() {

  double result = 0;
  cout << "Async task with lambda triggered, thread: " << this_thread::get_id() << endl;
  auto f2 = async(launch::async, [&result]() {
    cout << "Lambda task in thread: " << this_thread::get_id() << endl;
    for (int i = 0; i <= MAX; i++) {
      result += sqrt(i);
    }
  });
  f2.wait();
  cout << "Async task with lambda finish, result: " << result << endl << endl;
  
  return 0;
}

在上面這段代碼中,我們使用一個lambda表達式來編寫異步任務的邏輯,並通過launch::async明確指定要通過獨立的線程來執行任務,同時我們打印出了線程的id。

這段代碼輸出如下:

Async task with lambda triggered, thread: 0x11290d5c0
Lambda task in thread: 0x700007aa1000
Async task with lambda finish, result: 2.10819e+13

對於面向對象編程來說,很多時候肯定希望以對象的方法來指定異步任務。下面是一個示例:

// 12_async_task.cpp

class Worker {
public:
  Worker(int min, int max): mMin(min), mMax(max) {} // ①
  double work() { // ②
    mResult = 0;
    for (int i = mMin; i <= mMax; i++) {
      mResult += sqrt(i);
    }
    return mResult;
  }
  double getResult() {
    return mResult;
  }

private:
  int mMin;
  int mMax;
  double mResult;
};

int main() {
  Worker w(0, MAX);
  cout << "Task in class triggered" << endl;
  auto f3 = async(&Worker::work, &w); // ③
  f3.wait();
  cout << "Task in class finish, result: " << w.getResult() << endl << endl;

  return 0;
}

這段代碼有三處需要說明:

  1. 這裏通過一個類來描述任務。這個類是對前面提到的任務的封裝。它包含了任務的輸入參數,和輸出結果。
  2. work函數是任務的主體邏輯。
  3. 通過async執行任務:這裏指定了具體的任務函數以及相應的對象。請注意這裏是&w,因此傳遞的是對象的指針。如果不寫&將傳入w對象的臨時複製。

packaged_task

在一些業務中,我們可能會有很多的任務需要調度。這時我們常常會設計出任務隊列和線程池的結構。此時,就可以使用packaged_task來包裝任務。

如果你瞭解設計模式,你應該會知道命令模式

packaged_task綁定到一個函數或者可調用對象上。當它被調用時,它就會調用其綁定的函數或者可調用對象。並且,可以通過與之相關聯的future來獲取任務的結果。調度程序只需要處理packaged_task,而非各個函數。

packaged_task對象是一個可調用對象,它可以被封裝成一個std::fucntion,或者作爲線程函數傳遞給std::thread,或者直接調用。

下面是一個代碼示例:

// 13_packaged_task.cpp

double concurrent_worker(int min, int max) {
  double sum = 0;
  for (int i = min; i <= max; i++) {
    sum += sqrt(i);
  }
  return sum;
}

double concurrent_task(int min, int max) {
  vector<future<double>> results; // ①

  unsigned concurrent_count = thread::hardware_concurrency();
  min = 0;
  for (int i = 0; i < concurrent_count; i++) { // ②
    packaged_task<double(int, int)> task(concurrent_worker); // ③
    results.push_back(task.get_future()); // ④

    int range = max / concurrent_count * (i + 1);
    thread t(std::move(task), min, range); // ⑤
    t.detach();

    min = range + 1;
  }

  cout << "threads create finish" << endl;
  double sum = 0;
  for (auto& r : results) {
    sum += r.get(); // ⑥
  }
  return sum;
}

int main() {
  auto start_time = chrono::steady_clock::now();

  double r = concurrent_task(0, MAX);

  auto end_time = chrono::steady_clock::now();
  auto ms = chrono::duration_cast<chrono::milliseconds>(end_time - start_time).count();
  cout << "Concurrent task finish, " << ms << " ms consumed, Result: " << r << endl;
  return 0;
}

在這段代碼中:

  1. 首先創建一個集合來存儲future對象。我們將用它來獲取任務的結果。
  2. 同樣的,根據CPU的情況來創建線程的數量。
  3. 將任務包裝成packaged_task。請注意,由於concurrent_worker被包裝成了任務,我們無法直接獲取它的return值。而是要通過future對象來獲取。
  4. 獲取任務關聯的future對象,並將其存入集合中。
  5. 通過一個新的線程來執行任務,並傳入需要的參數。
  6. 通過future集合,逐個獲取每個任務的計算結果,將其累加。這裏r.get()獲取到的就是每個任務中concurrent_worker的返回值。

爲了簡單起見,這裏的示例只使用了我們熟悉的例子和結構。但在實際上的工程中,調用關係通常更復雜,你可以藉助於packaged_task將任務組裝成隊列,然後通過線程池的方式進行調度:

promise與future

在上面的例子中,concurrent_task的結果是通過return返回的。但在一些時候,我們可能不能這麼做:在得到任務結果之後,可能還有一些事情需要繼續處理,例如清理工作。

這個時候,就可以將promisefuture配對使用。這樣就可以將返回結果和任務結束兩個事情分開。

下面是對上面代碼示例的改寫:

// 14_promise_future.cpp

double concurrent_worker(int min, int max) {
  double sum = 0;
  for (int i = min; i <= max; i++) {
    sum += sqrt(i);
  }
  return sum;
}

void concurrent_task(int min, int max, promise<double>* result) { // ①
  vector<future<double>> results;

  unsigned concurrent_count = thread::hardware_concurrency();
  min = 0;
  for (int i = 0; i < concurrent_count; i++) {
    packaged_task<double(int, int)> task(concurrent_worker);
    results.push_back(task.get_future()); 

    int range = max / concurrent_count * (i + 1);
    thread t(std::move(task), min, range);
    t.detach();

    min = range + 1;
  }

  cout << "threads create finish" << endl;
  double sum = 0;
  for (auto& r : results) {
    sum += r.get();
  }
  result->set_value(sum); // ②
  cout << "concurrent_task finish" << endl;
}

int main() {
  auto start_time = chrono::steady_clock::now();

  promise<double> sum; // ③
  concurrent_task(0, MAX, &sum);

  auto end_time = chrono::steady_clock::now();
  auto ms = chrono::duration_cast<chrono::milliseconds>(end_time - start_time).count();
  cout << "Concurrent task finish, " << ms << " ms consumed." << endl;
  cout << "Result: " << sum.get_future().get() << endl; // ④
  return 0;
}

這段代碼和上面的示例在很大程度上是一樣的。只有小部分內容做了改動:

  1. concurrent_task不再直接返回計算結果,而是增加了一個promise對象來存放結果。
  2. 在任務計算完成之後,將總結過設置到promise對象上。一旦這裏調用了set_value,其相關聯的future對象就會就緒。
  3. 這裏是在main中創建一個promoise來存放結果,並以指針的形式傳遞進concurrent_task中。
  4. 通過sum.get_future().get()來獲取結果。第2點中已經說了:一旦調用了set_value,其相關聯的future對象就會就緒。

需要注意的是,future對象只有被一個線程獲取值。並且在調用get()之後,就沒有可以獲取的值了。如果從多個線程調用get()會出現數據競爭,其結果是未定義的。

如果真的需要在多個線程中獲取future的結果,可以使用shared_future

並行算法

從C++17開始。<algorithm><numeric> 頭文件的中的很多算法都添加了一個新的參數:sequenced_policy

藉助這個參數,開發者可以直接使用這些算法的並行版本,不用再自己創建併發系統和劃分數據來調度這些算法。

sequenced_policy可能的取值有三種,它們的說明如下:

變量 類型 C++版本 說明
execution::seq execution::sequenced_policy C++17 要求並行算法的執行可以不併行化
execution::par execution::parallel_policy C++17 指示並行算法的執行可以並行化
execution::par_unseq execution::parallel_unsequenced_policy C++17 指示並行算法的執行可以並行化、向量化

注意:本文的前面已經提到,目前clang編譯器還不支持這個功能。因此想要編譯這部分代碼,你需要使用gcc 9.0或更高版本,同時還需要安裝Intel Threading Building Blocks

下面還是通過一個示例來進行說明:

// 15_parallel_algorithm.cpp

void generateRandomData(vector<double>& collection, int size) {
  random_device rd;
  mt19937 mt(rd());
  uniform_real_distribution<double> dist(1.0, 100.0);
  for (int i = 0; i < size; i++) {
    collection.push_back(dist(mt));
  }
}

int main() {
  vector<double> collection;
  generateRandomData(collection, 10e6); // ①

  vector<double> copy1(collection); // ②
  vector<double> copy2(collection);
  vector<double> copy3(collection);

  auto time1 = chrono::steady_clock::now(); // ③
  sort(execution::seq, copy1.begin(), copy1.end()); // ④
  auto time2 = chrono::steady_clock::now();
  auto duration = chrono::duration_cast<chrono::milliseconds>(time2 - time1).count();
  cout << "Sequenced sort consuming " << duration << "ms." << endl; // ⑤

  auto time3 = chrono::steady_clock::now();
  sort(execution::par, copy2.begin(),copy2.end()); // ⑥
  auto time4 = chrono::steady_clock::now();
  duration = chrono::duration_cast<chrono::milliseconds>(time4 - time3).count();
  cout << "Parallel sort consuming " << duration << "ms." << endl;

  auto time5 = chrono::steady_clock::now();
  sort(execution::par_unseq, copy2.begin(),copy2.end()); // ⑦
  auto time6 = chrono::steady_clock::now();
  duration = chrono::duration_cast<chrono::milliseconds>(time6 - time5).count();
  cout << "Parallel unsequenced sort consuming " << duration << "ms." << endl;
}

這段代碼很簡單:

  1. 通過一個函數生成1000,000個隨機數。
  2. 將數據拷貝3份,以備使用。
  3. 接下來將通過三個不同的parallel_policy參數來調用同樣的sort算法。每次調用記錄開始和結束的時間。
  4. 第一次調用使用std::execution::seq參數。
  5. 輸出本次測試所使用的時間。
  6. 第二次調用使用std::execution::par參數。
  7. 第三次調用使用std::execution::par_unseq參數。

該程序的輸出如下:

Sequenced sort consuming 4464ms.
Parallel sort consuming 459ms.
Parallel unsequenced sort consuming 168ms.

可以看到,性能最好的和最差的相差了超過26倍。

結束語

在本篇文章中,我們介紹了C++語言中新增的併發編程API。雖然這部分內容已經不少(大部分人很難一次性搞懂所有這些內容,包括我自己),但實際上還有一個很重要的話題我們沒有觸及,那就是“內存模型”。

C++內存模型是C++11標準中最重要的特性之一。它是多線程環境能夠可靠工作的基礎。考慮到這部分內容還需要比較多的篇幅來說明,因此我們會在下一篇文章中繼續討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章