C++多线程编程
并发与并行
并发的概念在多线程编程中很重要,值得是多个进程同时进行处理。在单核时代,多进程的并发只能交替进行。
现在的多核时代就可以并行了
多线程的并发与多进程的并发
- 场景一:你和小伙伴要开发一个项目,但小伙伴们放寒假都回家了,你们只能通过QQ聊天、手机通话、发送思维导图等方式来进行交流,总之你们无法很方便地进行沟通。好处是你们各自工作时可以互不打扰。
- 场景二:你和小伙伴放假都呆在学校实验室中开发项目,你们可以聚在一起使用头脑风暴,可以使用白板进行观点的阐述,总之你们沟通变得更方便有效了。有点遗憾的是你在思考时可能有小伙伴过来问你问题,你受到了打扰。
这两个场景描绘了并发的两种基本途径。每个小伙伴代表一个线程,工作地点代表一个处理器。场景一中每个小伙伴是一个单线程的进程,他们拥有独立的处理器,多个进程同时执行;场景二中只有一个处理器(注意这个处理器必须是多核的),所有小伙伴都是属于同一进程的线程。
多进程并发
多个进程独立地运行,它们之间通过进程间常规的通信渠道传递讯息(信号,套接字,文件,管道等),这种进程间通信不是设置复杂就是速度慢,这是因为为了避免一个进程去修改另一个进程,操作系统在进程间提供了一定的保护措施,当然,这也使得编写安全的并发代码更容易。运行多个进程也需要固定的开销:进程的启动时间,进程管理的资源消耗。
多线程并发
在当个进程中运行多个线程也可以并发。线程就像轻量级的进程,每个线程相互独立运行,但它们共享地址空间,所有线程访问到的大部分数据如指针、对象引用或其他数据可以在线程之间进行传递,它们都可以访问全局变量。进程之间通常共享内存,但这种共享通常难以建立且难以管理,缺少线程间数据的保护。因此,在多线程编程中,我们必须确保每个线程锁访问到的数据是一致的。
C++11 的多线程
C++11的标准库中提供了多线程库,使用时需要#include <thread>
头文件,该头文件主要包含了对线程的管理类std::thread
以及其他管理线程相关的类。下面是使用C++多线程库的一个简单示例:
#include <iostream>
#include <thread>
using namespace std;
void output(int i)
{
cout << i << endl;
}
int main()
{
for (uint8_t i = 0; i < 4; i++)
{
thread t(output, i);
t.detach();
}
getchar();
return 0;
}
在一个for循环内,创建4个线程分别输出数字0、1、2、3,并且在每个数字的末尾输出换行符。语句thread t(output, i)
创建一个线程t
,该线程运行output
,第二个参数i是传递给output
的参数。t在创建完成后自动启动,t.detach
表示该线程在后台允许,无需等待该线程完成,继续执行后面的语句。这段代码的功能是很简单的,如果是顺序执行的话,其结果很容易预测得到0 \n 1 \n 2\n 3 \n
但是在并行多线程下,其执行的结果就多种多样了,下图是代码一次运行的结果:
可以看出,首先输出了01,并没有输出换行符;紧接着却连续输出了2个换行符。不是说好的并行么,同时执行,怎么还有先后的顺序?这就涉及到多线程编程最核心的问题了资源竞争。CPU有4核,可以同时执行4个线程这是没有问题了,**但是控制台却只有一个,同时只能有一个线程拥有这个唯一的控制台,**将数字输出。将上面代码创建的四个线程进行编号:t0,t1,t2,t3,分别输出的数字:0,1,2,3。参照上图的执行结果,控制台的拥有权的转移如下:
- t0拥有控制台,输出了数字0,但是其没有来的及输出换行符,控制的拥有权却转移到了t1;
- t1完成自己的输出,t1线程完成 (1\n)
- 控制台拥有权转移给t0,输出换行符 (\n)
- t2拥有控制台,完成输出 (2\n)
- t3拥有控制台,完成输出 (3\n)
由于控制台是系统资源,这里控制台拥有权的管理是操作系统完成的。但是,假如是多个线程共享进程空间的数据,这就需要自己写代码控制,每个线程何时能够拥有共享数据进行操作。共享数据的管理以及线程间的通信,是多线程编程的两大核心。
线程管理
每个应用程序至少有一个进程,而每个进程至少有一个主线程,除了主线程外,在一个进程中还可以创建多个线程。每个线程都需要一个入口函数,入口函数返回退出,该线程也会退出,主线程就是以main
函数作为入口函数的线程。在C++ 11的线程库中,将线程的管理在了类std::thread
中,使用std::thread
可以创建、启动一个线程,并可以将线程挂起、结束等操作。
启动一个线程
C++ 11的线程库启动一个线程是非常简单的,只需要创建一个std::thread
对象,就会启动一个线程,并使用该std::thread
对象来管理该线程。
do_task();
std::thread(do_task);
当线程启动后,一定要在和线程相关联的thread
销毁前,确定以何种方式等待线程执行结束。C++11有两种方式来等待线程结束:
- detach方式,启动的线程自主在后台运行,当前的代码继续往下执行,不等待新线程结束。前面代码所使用的就是这种方式。
- join方式,等待启动的线程完成,才会继续往下执行。假如前面的代码使用这种方式,其输出就会0,1,2,3,因为每次都是前一个线程输出完成了才会进行下一个循环,启动下一个新线程。
无论在何种情形,一定要在thread
销毁前,调用t.join
或者t.detach
,来决定线程以何种方式运行。当使用join
方式时,会阻塞当前代码,等待线程完成退出后,才会继续向下执行;而使用detach
方式则不会对当前代码造成影响,当前代码继续向下执行,创建的新线程同时并发执行,这时候需要特别注意:创建的新线程对当前作用域的变量的使用,创建新线程的作用域结束后,有可能线程仍然在执行,这时局部变量随着作用域的完成都已销毁,如果线程继续使用局部变量的引用或者指针,会出现意想不到的错误,并且这种错误很难排查。例如:
auto fn = [](int *a){
for (int i = 0; i < 10; i++)
cout << *a << endl;
};
[]{
int a = 100;
thread t(fn, &a);
t.detach();
}();
在lambda表达式中,使用fn启动了一个新的线程,在装个新的线程中使用了局部变量a的指针,并且将该线程的运行方式设置为detach。这样,在lamb表达式执行结束后,变量a被销毁,但是在后台运行的线程仍然在使用已销毁变量a的指针,其输出结果如下:
只有第一个输出是正确的值,后面输出的值是a已被销毁后输出的结果。所以在以detach的方式执行线程时,要将线程访问的局部数据复制到线程的空间(使用值传递),一定要确保线程没有使用局部变量的引用或者指针,除非你能肯定该线程会在局部作用域结束前执行结束。当然,使用join方式的话就不会出现这种问题,它会在作用域结束前完成退出。
向线程传递参数
向线程调用的函数传递参数也是很简单的,只需要在构造thread
的实例时,依次传入即可。例如:
void func(int *a,int n){}
int buffer[10];
thread t(func,buffer,10);
t.join();
需要注意的是,默认的会将传递的参数以拷贝的方式复制到线程空间,即使参数的类型是引用。例如:
void func(int a,const string& str);
thread t(func,3,"hello");
如果在线程中使用引用来更新对象时,就需要注意了。默认的是将对象拷贝到线程空间,其引用的是拷贝的线程空间的对象,而不是初始希望改变的对象。如下:
class _tagNode
{
public:
int a;
int b;
};
void func(_tagNode &node)
{
node.a = 10;
node.b = 20;
}
void f()
{
_tagNode node;
thread t(func, node);
t.join();
cout << node.a << endl ;
cout << node.b << endl ;
}
在线程内,将对象的字段a和b设置为新的值,但是在线程调用结束后,这两个字段的值并不会改变。这样由于引用的实际上是局部变量node
的一个拷贝,而不是node本身。在将对象传入线程的时候,调用std::ref
,将node
的引用传入线程,而不是一个拷贝。
转移线程的所有权
thread是可移动的(movable)的,但不可复制(copyable)。可以通过move来改变线程的所有权,灵活的决定线程在什么时候join或者detach。
thread t1(f1);
thread t3(move(t1));
将线程从t1转移给t3,这时候t1就不再拥有线程的所有权,调用t1.join
或t1.detach
会出现异常,要使用t3来管理线程。这也就意味着thread可以作为函数的返回类型,或者作为参数传递给函数,能够更为方便的管理线程。
线程的标识类型为``std::thread::id```,有两种方式获得到线程的id。
- 通过thread的实例调用
get_id()
直接获取 - 在当前线程上调用
this_thread::get_id()
获取
总结
本文主要介绍了C++11引入的标准多线程库的一些基本操作。有以下内容:
- 线程的创建
- 线程的执行方式,join或者detach
- 向线程函数传递参数,需要注意的是线程默认是以拷贝的方式传递参数的,当期望传入一个引用时,要使用std::ref进行转换
- 线程是movable的,可以在函数内部或者外部进行传递
- 每个线程都一个标识,可以调用get_id获取。
参考
https://www.cnblogs.com/wangguchangqing/p/6134635.html
https://www.cnblogs.com/lpxblog/p/5190438.html