線程安全之std::atomic探索

2021-03-02 魅力嵌入式

多線程的數據訪問一直很讓程式設計師們頭大,有什麼簡便方法來實現多線程間的通呢?試試C++11封裝的原子數據類型(std::atomic)吧。


什麼是原子數據類型

簡單地說,原子數據類型能保證線程之間不會發生數據競爭(data race),因此能保證線程安全(thread safe)。

對於我們用戶來說,就不需要對需要多線程訪問的數據添加互斥鎖。從不同線程訪問某個原子對象是良性(well-defined) 行為,而通常對於非原子類型而言,並發訪問某個對象(如果不做任何同步操作)會導致未定義(undefined) 行為發生。因此,從實現的原理上來,可以理解為原子類型在自己內部添加了互斥鎖。

我們先創建一個CMake工程,並創建兩個線程來操作同一個全局變量,然後來編譯運行,查看它的結果。接下來,我們會針對這個結果進行改造,使之達到我們想要的「線程安全」的要求。測試環境

工程1:不使用原子數據類型

第一個工程,演示的是如果不使用原子數據類型,程序會產生一定的錯誤。工程代碼可在以上GitHub連結中找到,工程文件名為no-atomic.cpp,內容如下:

#include <atomic>
#include <ctime>
#include <iostream>
#include <thread>
#include <unistd.h>

int data( 0 );

void threadFunc() {
for ( int i = 0; i < 2000; i++ ) {
usleep( 1 );
data++;
}
}

int main( int argc, char* argv[] ) {
std::thread th1( threadFunc );
std::thread th2( threadFunc );
th1.join();
th2.join();
usleep( 20000 );
std::cout << "data = " << data << std::endl;
return 0;
}

這裡的data是一個全局變量(當然實際工程當中使用全局變量不是一個好的習慣,我們這裡只是演示),初始值為0。

在main()函數裡,我們創建了兩個調用threadFunc()的線程:th1()和th2()。

threadFunc()函數很簡單,就是執行data++兩千次。

所以,我們這個小工程,期望兩個線程對data操作之後,它最終的結果是4000。但是,實際情況是這樣的嗎?我們來編譯(會編譯工程裡的所有文件)並運行:

cd <path/to/article-003-atomic/
mkdir build
cd build
cmake ..
make
./no-atomic

我們得到的結果是:

$ ./no-atomic
summation = 3911

而且,多次運行,它的結果是不一樣的!這就說明了, 這個工程的兩個線程在訪問全局變量的時候,產生了一定的錯誤。簡單的說,它們之間發生了數據競爭的情況,導致讀寫出錯。下面,我們針對這一個工程,做一個小小的改造。

工程2:原子操作-簡單用法

我們把上面程序裡的全局變量data的定義做一下修改,即把:

int data( 0 );

修改成:

std::atomic< int > data( 0 );

最終的文件,請查看第二個工程文件atomic-simple.cpp。簡單來說,就是data的數據類型不是int型,而是std::atomic<int>型。

我們編譯之後再運行:

$ ./atomic-simple
data = 4000

這一次,它的運行結果是4000了!說明原子數據類型在這裡解決了之前我們遇到的問題。是不是很神奇呢?

關於CMake編譯

我們使用CMake來管理前面的兩個工程。以下是前面兩個工程CMakeLists.txt文件當中的相關內容:

#
add_executable(
no-atomic
no-atomic.cpp
)
target_link_libraries(
no-atomic
pthread
)

#
add_executable(
atomic-simple
atomic-simple.cpp
)
target_link_libraries(
atomic-simple
pthread
atomic
)

我們看到,第二個工程,我們在target_link_libraries()裡添加了atomic,其實這就是聲明連結std::atomic的標準庫文件libatomic.so.*,這個文件在哪裡呢?我們可以使用以下指令來查看:

ldconfig -p | grep libatomic

我們得到的返回是:

libatomic.so.1 (libc6,x32) => /libx32/libatomic.so.1
libatomic.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libatomic.so.1
libatomic.so.1 (libc6) => /lib32/libatomic.so.1

第二行,其實就指明了,該庫文件位於/usr/lib/x86_64-linux-gnu/目錄下。其實std::atomic不僅僅可以應用於基本的數據類型,也適用於類、結構體等複雜數據類型,下面開始改造。工程3:原子操作-指針用法

對於類和結構體這樣稍微複雜的數據類型,工程2的簡單方法並不適用。這時,我們需要使用指針,先看代碼(工程文件為atomic-class.cpp):

class Test {
public:
Test( double a, float b, double c ) {
this->a = a;
this->b[0] = b;
this->c.push_back( c );
};
double a;
float b[100];
std::vector< double > c;
};

std::atomic< Test* > msg;

void threadFunc() {
for ( int i = 0; i < 2000; i++ ) {
usleep( 1 );
Test* n = msg.load( std::memory_order_relaxed );
n->a++;
n->c.push_back( i );
msg.store( n, std::memory_order_relaxed );
}
}

int main( int argc, char* argv[] ) {
msg = new Test( 0, 0, 0 );
std::thread th1( threadFunc );
std::thread th2( threadFunc );
th1.join();
th2.join();
Test* n = msg.load( std::memory_order_relaxed );
std::cout << "msg->a: " << n->a << "\n"
<< "msg->b[0]: " << n->b[0] << "\n"
<< "msg->c.size(): " << n->c.size() << "\n";
}

我們運行一下:

$ ./atomic-class
msg->a: 3995
msg->b[0]: 0
msg->c.size(): 3995

這一次,結果似乎又不符合我們的預期呢。是不是std::atomic有問題?其實並不是。真正的原因是因為msg沒有被鎖死:當線程th1()讀取了msg的指針後,會將msg的指針釋放出來,這時線程th2()也可以讀取msg的指針,兩個線程對a同時相加,再分別賦值給了a。std::atomic的原子操作,在這個工程裡面,只保證了msg的指針在多線程的讀寫過程中具有原子性(即讀寫不會出錯),但並沒有保證指針所指向的數據具有原子性。

另外,這個工程,還有可能出現內存錯誤:double free or corruption (!prev) 而不能運行。

這裡,我們需要對數據進行上鎖操作,改造繼續!

工程4:帶mutex的原子操作

在工程3的基本上,我們添加了std::mutex類型的數據mutex,關鍵代碼如下(具體代碼可見工程文件atomic-mutex.cpp):

class Test {
public:
...
...
std::mutex mutex;
};

void threadFunc() {
for ( int i = 0; i < 2000; i++ ) {
usleep( 1 );
Test* n = msg.load( std::memory_order_relaxed );
n->mutex.lock();
n->a++;
n->c.push_back( i );
n->mutex.unlock();
msg.store( n, std::memory_order_relaxed );
}
}

即,我們按以下流程來處理數據:

編程不易,總算是出現正確數據了:

$ ./atomic-mutex
msg->a: 4000
msg->b[0]: 0
msg->c.size(): 4001

這幾個小例子大家學會了嘛?下面接著再來看看atomic的常用成員函數吧。

原子操作常用成員函數

第四個工程,我們使用了load()和store()成員函數,分別用於讀取和保存被封裝的值。而且,我們指定了內存序(Memory Order)的一種,即std::memory_order_relaxed。內存序還可以為以下的值:

typedef enum memory_order {
memory_order_relaxed, // 不對執行順序做保證
memory_order_acquire, // 本線程中,所有後續的讀操作必須在本條原子操作完成後執行
memory_order_release, // 本線程中,所有之前的寫操作完成後才能執行本條原子操作
memory_order_acq_rel, // 同時包含 memory_order_acquire 和 memory_order_release
memory_order_consume, // 本線程中,所有後續的有關本原子類型的操作,必須在本條原子操作完成之後執行
memory_order_seq_cst // 全部存取都按順序執行
} memory_order;

不同的內存序應該用在什麼樣的條件下,還請小夥伴自己研究吧。本文只能點到為止啦。

原子操作還有另外兩個重要的成員函數:

is_lock_free()用來判斷對象是否具備 lock-free 的特性,如果訪問對象具備無鎖的特性,當多個線程訪問該對象時就不會導致線程阻塞。

exchange() :

T exchange (T val, memory_order sync = memory_order_seq_cst) volatile noexcept;
T exchange (T val, memory_order sync = memory_order_seq_cst) noexcept;

它會將 val 指定的值替換掉之前該原子對象封裝的值,並返回之前該原子對象封裝的值,整個過程是具有原子性的。因此 exchange() 操作也稱為 read-modify-write 操作。

總結

std::atomic對於多線程通信來說,大大降低了開發的難度,提高了編程效率,並且簡單易用。多試試吧,它將會給你意外的驚喜。

參考資料

C++ 並發編程指南

https://github.com/forhappy/Cplusplus-Concurrency-In-Practice/blob/master/zh/chapter7-Atomic/7.3%20Atomic-tutorial.md

C++ 並發指南-atomic 指針的使用(三)

https://blog.csdn.net/weixin_40490238/article/details/108542060

相關焦點

  • 走進C++11(三十七)原子操作之 std::atomic
    每個 std::atomic 模板的實例化和全特化定義一個原子類型。若一個線程寫入原子對象,同時另一線程從它讀取,則行為良好定義(數據競爭的細節我們會在下節--內存模型裡講解)另外,對原子對象的訪問可以建立線程間同步,並按 std::memory_order 所對非原子內存訪問定序。 需要注意的是,std::atomic 既不可複製亦不可移動。
  • UNIX(多線程):03--- 認識std::thread
    <thread> 頭文件摘要<thread> 頭文件聲明了 std::thread 線程類及 std::swap (交換兩個線程對象)輔助函數。另外命名空間 std::this_thread 也聲明在 <thread> 頭文件中。
  • C++11並發編程:多線程std::thread
    所需頭文件< thread >二:構造函數1.默認構造函數thread() noexcept一個空的std::thread執行對象2.初始化構造函數templateexplicit thread(Fn&& fn, Args&&… args);創建std::thread執行對象
  • C++ STL 容器如何解決線程安全的問題?
    眾所周知,STL容器不是線程安全的。對於vector,即使寫方(生產者)是單線程寫入,但是並發讀的時候,由於潛在的內存重新申請和對象複製問題,會導致讀方(消費者)的迭代器失效。實際表現也就是招致了core dump。另外一種情況,如果是多個寫方,並發的push_back(),也會導致core dump。
  • JAVA並發之AtomicInteger原理分析
    假設現在我們要實現多線程應用中的int值自增(單個應用範圍),應該怎麼做呢?value變量做自增操作,通過對increaseBySync方法加synchronized鎖實現了線程安全的int值自增。synchronized性能問題當多個線程訪問某個syncronized方法或者代碼塊的時候,線程間的切換和其他線程等待的時間間隔(取決於OS實現,存在不確定性),由此帶來的性能損耗是比較大的。
  • Effective C++條款13 C++基本功之智能指針
    在舊式的C++程序開發過程中,我們會比較習慣用傳統的裸指針來管理資源,通過成對使用new和delete來保證內存安全,這種做法不會造成太大問題,只是在某些情況下會出現內存難於管理的局面。例如:auto pointer = std::make_shared<int>(10);std::unique_ptr 是一種獨佔的智能指針,它禁止其他智能指針與其共享同一個對象,從而保證代碼的安全,如下所示:
  • 當我們談論shared_ptr的線程安全性時,我們在談論什麼
    然而當C++程式設計師們在談論shared_ptr是不是線程安全的的時候,還時常存在分歧。確實關於shared_ptr 的線程安全性不能直接了當地用安全或不安全來簡單回答的,下面我來探討一下。當然這裡只是一個例子,線程不安全還可能由其他原因導致。你認為shared_ptr有哪些線程安全隱患?shared_ptr 可能的線程安全隱患大概有如下幾種,一是引用計數的加減操作是否線程安全,二是shared_ptr修改指向時,是否線程安全。
  • 如何編寫線程安全的代碼?
    本文都是圍繞著上述兩個核心點來講解的,現在我們就可以正式的聊聊編程中的線程安全了。我們說一段代碼是線程安全的,若且唯若我們在多個線程中同時且多次調用的這段代碼都能給出正確的結果,這樣的代碼我們才說是線程安全代碼,Thread Safety,否則就不是線程安全代碼,thread-unsafe.。
  • C++ atomic memory model和Arm實現方式
    C++作為高級語言本身應該和CPU的硬體是無關的,但是為了使一些原子(atomic)操作能夠在CPU更有效率的運行,避免因為原子操作帶來的memory ordering要求對系統性能的影響,C++的原子操作會帶有memory ordering的限定。本文將通過圖表的方式介紹C++的atomic memory ordering和其在Arm構架上的實現。
  • UNIX(多線程):07---線程啟動、結束,創建線程多法、join,detach
    (myprint);mythread.join();std::cout << "Main Thread" << std::endl;return 0;}threadthread mythread(myprint);join()//阻塞主線程並等待子線程執行完mythread.join();
  • UNIX(多線程):08---線程傳參詳解,detach()陷阱,成員函數做線程函數
    ::cout << "主線程收尾" << std::endl;return 0;}();std::cout << "主線程收尾" << std::endl;return 0;}
  • 多線程程序中操作的原子性
    在多線程程序中原子操作是一個非常重要的概念,它常常用來實現一些同步機制,同時也是一些常見的多線程Bug的源頭。本文主要討論了三個問題:1. 多線程程序中對變量的讀寫操作是否是原子的?2. 多線程程序中對Bit field(位域)的讀寫操作是否是線程安全的?3. 程式設計師該如何使用原子操作?
  • C++多線程編程之創建線程的幾種方法
    請看下面的示例程序:#include <iostream>using namespace std;int main(){        cout << "I love China!"
  • UNIX(多線程):14---理解線程構造函數
    ::string m) {}std::thread t1(function_1);std::thread t2(function_2, 1);std::thread t3(function_3, 1, "hello");t1.join();t2.join();t3.join();實驗的時候還發現一個問題,如果將重載的函數作為線程的入口函數,會發生編譯錯誤!
  • 線程安全代碼到底是怎麼編寫的?
    因此我們可以看到,這裡有兩種情況: 線程私有資源,沒有線程安全問題 共享資源,線程間以某種秩序使用共享資源也能實現線程安全。本文都是圍繞著上述兩個核心點來講解的,現在我們就可以正式的聊聊編程中的線程安全了。
  • C 線程的創建
    線程創建很容易,直接調用std::thread,就創建一個新線程了。該線程拿到任務後立即開始執行。線程的創建者(父線程)必須管理創建的線程(子線程),應該等到子線程完成其任務或者讓子線程從自己身上脫離。子線程可以通過複製或引用獲取任務執行的參數。
  • 多線程編程(1):從thread開始,邁入多線程的大門
    本文要完成兩個目標:使用std::thread在如下的demo中,在主線程中使用std::thread創建3個子線程,線程入口函數是do_some_word,在主線程運行結束前等待子線程結束。在構造std::thread對象的時候,如果沒有設置線程入口函數,則線程_M_id._M_thread的值是0。比如下面的demo中,trd沒有設置線程入口函數,trd調用默認構造函數時,trd的_M_id._M_thread會被初始化為0。
  • C++11線程、鎖和條件變量
    調用join函數後,該調用線程(本例中指的就是主線程)就會在join進來進行執行的線程t結束執行之前,一直處於阻塞狀態。如果該線程函數執行結束後返回了一個值,該值也將被忽略。不過,該函數可以接受任意數量的參數。