C++并发编程一书的笔记,主要是关于std::thread的内容。
除了阅读英文原版书外,还参考了以下视频:
C++ 并发编程(1) 线程基础,为什么线程参数默认传参方式是值拷贝?
一.线程控制 1.启动线程与等待结束 可以直接传入函数,重载()的类(注意如果直接初始化一个类对象传入,要加括号) ,或lambda表达式创建线程,也可以绑定类的函数。参数也直接在后面传入。通过join等待线程运行结束。
1 2 std::thread t (func,arg1,arg2) ;t.join ();
示例程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 class bg_task {public : void operator () () { std::cout<< "bg_task running..." <<std::endl; } }; void func1 (const std::string a) { std::cout<<a<<std::endl; } int main () { std::thread t1 (func1,"thread t1 call func1" ) ; std::thread t2 ((bg_task())) ; std::thread t3 ([](std::string s){std::cout<<s<<std::endl;}, "thread t3 running..." ) ; problem1 (); t1.join (); t2.join (); t3.join (); return 0 ; }
为了保证主线程发生异常时,子线程也能回收,需要使用try catch来捕获主线程的异常。为了方便编写程序,通常使用RAII思想,封装一个类来保证子线程一定可以回收:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class thread_guard { std::thread &t; public : explicit thread_guard (std::thread &_t ) : t(_t){ } ~thread_guard (){ if (t.joinable ()){ t.join (); } } thread_guard (thread_guard const &) = delete ; thread_guard& operator =(thread_guard const &) = delete ; } int func () {return 0 };void f () { std::thread t (func) ; thread_guard g (t) ; }
也可以不使用join回收线程,只需要设置其为detach状态:
绑定类的函数的示例如下,如果有需要,参数补在后面就可以:
1 2 3 4 5 6 7 8 9 10 11 class X {public : void do_lengthy_work () { std::cout << "do_lengthy_work " << std::endl; } }; void bind_class_oops () { X my_x; std::thread t (&X::do_lengthy_work, &my_x) ; t.join (); }
2.传参 和一般的函数传参一样,要注意保证传进去的变量在运行期间存在:
1 2 3 4 5 6 7 8 9 10 11 12 void func2 (int &a) { for (int i=0 ;i<5 ;i++){ a = i; } } void problem1 () { int x = 0 ; std::thread t (func2, std::ref(x)) ; t.detach (); }
在上面的代码中可以看到,thread的构造需要将引用参数使用std::ref封装,因为thread会将所有参数作为按值移动处理,再调用函数。
还有下面这种传指针的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 void f (int i, std::string const &s) ;void oops (int arg) { char buffer[1024 ]; sprintf (buffer,"%i" ,arg); std::thread (f,3 ,buffer); } void oops (int arg) { char buffer[1024 ]; sprintf (buffer,"%i" ,arg); std::thread (f,3 ,std::string (buffer)); }
在传递参数时,有些需要传递的参数是不可复制的,这时可以通过move将其传递(move将左值转换为右值,使用移动构造函数):
1 2 3 4 5 6 7 8 9 10 11 12 void deal_unique (std::unique_ptr<int > p) { std::cout << "unique ptr data is " << *p << std::endl; (*p)++; std::cout << "after unique ptr data is " << *p << std::endl; } void move_oops () { auto p = std::make_unique <int >(100 ); std::thread t (deal_unique, std::move(p)) ; t.join (); }
3.所有权转移 线程管理变量没有拷贝构造和赋值函数,线程的所有权通过move进行转移:
1 2 3 4 5 void func1 () ;void func2 () ;std::thread t1 (func1) ;std::thread t2 = std::move (t1);
4.运行时决定线程数 在选定线程数时,通常希望线程数不大于硬件核数。可以使用std::thread::harware_concurrency()来确定线程数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <iostream> #include <thread> const int N = 1e5 +5 ;const int MIN_SIZE = 1000 ;void func (int i) { std::cout<<"thread" <<i<<" running..." <<std::endl; } int main () { int k = std::thread::hardware_concurrency (); int max_threads = N/MIN_SIZE; int num_threads = std::min (k!=0 ?k:2 , max_threads); std::thread threads[num_threads-1 ]; std::cout<<"hardware_concurrency is " <<k<<" and num_threads is " <<num_threads<<std::endl; for (int i=0 ;i<num_threads-1 ;i++){ threads[i] = std::thread (func,i); } std::cout<<"main thread running..." <<std::endl; for (int i=0 ;i<num_threads-1 ;i++){ threads[i].join (); } return 0 ; }
不均匀的部分由主线程来完成,创建num_threads-1个线程。
二.共享数据 1.使用锁保护共享数据 锁的使用
C++提供了锁来在多线程时保护共享数据,并提供了lock_guard 类,实现锁的RAII,还提供了一个unique_lock 类来配合条件变量的使用,unique_lock可以手动解锁或用于条件变量中解锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #include <list> #include <mutex> std::list<int > li; std::mutex mtx; void add_to_list (int val) { std::lock_guard<std::mutex> guard (mtx) ; li.push_back (val); } void wait_fn (ThreadState* thread_state) { std::unique_lock<std::mutex> lk (*thread_state->mutex_) ; thread_state->condition_variable_->wait (lk); thread_state->counter_++; printf ("Lock re-acquired after wait()...\n" ); lk.unlock (); }
并不是添加了锁,就可以保护共享数据,通常锁会封装到需要操作的类里,而如果函数返回指针或引用,就会导致锁无法控制对数据的修改和访问。不仅是返回指针和引用,调用函数也有风险,如果作为参数将指针或引用传入,函数就可以不需要锁也能访问共享数据。
除了以上情况,还有一种情况也不能保证对共享数据的访问不会导致竞态,这种情况是由于接口本身的设计导致的。如果多个线程对一个栈进行操作,栈只有一个元素,两个栈轮流调用empty()判断栈不空,然后都pop()出栈,就会产生错误。类似的情况是都在判空后调用top(),两个线程看到相同的值,然后各pop一次,导致一个值丢失。这是设计上的问题,考虑stack>,vector的复制是要开辟空间然后复制数据的,如果空间开辟不够,就会产生std:bad_alloc,如果pop直接获取元素,可能会在复制时失败,而元素也出栈,这个元素就丢失了。为了解决这个问题,stl设计者将操作分为top和pop,这样top没有取到值会先产生std::bad_alloc,数据不会丢失。
解决上述竞态问题有不同的方案:
Option 1 :传递引用。这种方式的局限性在于需要在调用pop之前进行构造,这是消耗时间与资源的,并且对于用户定义的类可能不支持赋值操作。
1 2 std::vector<int > result; s.pop (result);
Option2: 使用会抛出异常的拷贝构造和移动构造。确定是不可能在编译时找出不抛出异常的这两种构造。
Option3: 返回pop item的指针。返回指针能有效解决传值失败的问题,如果采用这种方法,最好使用shared_ptr,可以避免内存泄露。
以上的方案也可以同时采用,书中给出了线程安全的栈的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 struct empty_stack : std::exception{ const char * what () const throw () ; }; template <typename T>class threadsafe_stack { private : std::stack<T> data; mutable std::mutex m; public : threadsafe_stack () {} threadsafe_stack (const threadsafe_stack& other) { std::lock_guard<std::mutex> lock (other.m) ; data = other.data; } threadsafe_stack& operator =(const threadsafe_stack&) = delete ; void push (T new_value) { std::lock_guard<std::mutex> lock (m) ; data.push (std::move (new_value)); } std::shared_ptr<T> pop () { std::lock_guard<std::mutex> lock (m) ; if (data.empty ()) throw empty_stack (); std::shared_ptr<T> const res (std::make_shared<T>(data.top())) ; data.pop (); return res; } void pop (T& value) { std::lock_guard<std::mutex> lock (m) ; if (data.empty ()) throw empty_stack (); value = data.top (); data.pop (); } bool empty () const { std::lock_guard<std::mutex> lock (m) ; return data.empty (); } };