对于多线程编程,一个是并行,另外一个是并发。并行是指两个或多个独立的操作同时进行。并发是指在一个时间段内执行多个操作。例如,在常见的四核四线程可以并行四个线程,但是对于四核八线程是使用了超线程技术,把一个物理模拟为两个逻辑核心。
1 并发编程的方法
1.1 多进程并行
使用多进程并发就是将一个应用程序分为多个独立的进程(每一个进程只有一个线程),这些独立的进程之间可以相互通信。由于操作系统对进程提供了大量的保护机制,以避免一个进程修改了另一个进程的数据,使用多进程比多线程更容易写出安全的代码。但是相应的也有弊端:
(1)在进程件的通信,无论是使用信号、套接字,还是文件、管道等方式,其使用要么比较复杂,要么就是速度较慢或者两者兼而有之。
(2)运行多个进程的开销很大,操作系统要分配很多的资源对进程进行管理。
1.2 多线程并行
多线程并发指的是在同一个进程中执行多个线程。线程是轻量级的进程,每个线程可以独立的运行不同的指令序列,但是线程不独立的拥有资源,依赖于创建它的进程而存在。同一进程中的多个线程共享相同的地址空间,可以访问进程中的大部分数据,指针和引用可以在线程间进行传递。由于缺少操作系统提供的保护机制,在多线程共享数据及通信时,需要对共享数据段进行更好的处理。
2 C++11 线程的使用方式
我们使用C++11中的标准库进行测试,需要#include <thread>
。
#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();
}
return 0;
}
使用thread
创建一个线程对象,第一个参数是一个函数名句柄,第二参数是传入到函数名中的参数。使用detach()
表明该线程是在后台进行处理,不进行绑定。在上面的代码中,每一个线程都是运行在不同的物理核心上面的,但是对于输出命令行在一个时间结点只能一个线程在占用。因此出现了一个关键的问题资源竞争
。
对于多线程程序而言,共享数据的管理
以及线程之间的通信
是两个比较大的问题。
3 线程管理
每个应用程序至少有一个进程,而每个进程至少有一个主线程,除了主线程外,在一个进程中还可以创建多个线程。每个线程都需要一个入口函数,主线程就是以main
作为入口函数的线程。在C++11的标准库中,可以使用std::thread
创建启动一个线程,也可以将线程挂起或者结束等操作。
3.1 启动一个线程
1.使用函数名进程创建
void function(args) {
...
}
std::thread thread(function, args);
2.使用匿名函数(lambda表达式)进行创建
for(int i=0; i < 4; ++i) {
thread t([i]{
cout << i << endl;
});
t.detach();
}
3.重载()
运算符的类
class Task
{
public:
void operator()(int i)
{
cout << i << endl;
}
};
int main()
{
for (uint8_t i = 0; i < 4; i++)
{
Task task;
thread t(task, i);
t.detach();
}
}
通过重载运算符隐藏了实际的线程控制的函数。
如果是类构建的对象,在创建线程的时候,如果不是一个已经创建好的对象,而是一个临时变量,那么不能使用上面的表现形式。
std::thread thread(Task());
这样子是错误的,会被认为是函数的声明式,因此应该改为下面的形式。
std::thread thread{Task{}};
4.关于detach
的线程作用域
当线程启动之后,必须在线程相关联的thread
对象销毁前,确定以何种方式等待线程执行结束。
(1)detach
::启动的线程自主在后台执行,当前的代码往下执行,不等待新线程的结束。
(2)join
:等待启动的线程完成,才会继续进行。每一个线程完成之后,才会进入下一次循环。
注意:对于detach
方式,创建的新线程对当前作用域的变量的使用。创建新线程的作用域结束以后,可能线程还在继续,如果此时含有该线程对原先循环部分局部变量的引用或者是指针,那么就会出现错误。
auto fn = [](int *a){
for(int i=0; i < 10; ++i) {
cout << *a << endl;
}
};
int main() {
for(int i=0; i < 100; i++) {
int a = 100;
// 传递引用
thread t(fn, &a);
t.detach();
}
return 0;
}
输出结果:
100100100100
100
1937141752
在线程还在循环输出*a
的时候,此时线程外部循环已经结束,因此再次调用&a
的时候,找不到原有的内存位置。从而出现错误输出。因此在使用的时候,要么使用join
的方式,要么使用传值方式,而不是传指针或者传引用。
3.2 异常情况下等待线程完成
对于detach
模式,在执行函数thread.detach()
之后,如果因为异常导致thread
被销毁,但是线程依然可以在后台执行到结束。
对于join
模式,如果在执行thread.join()
函数前,发生异常,那么线程会跟随thread
对象一起失效。因此需要对异常进行处理,在函数返回之前进行join()
。
void func() {
thread t([]{
cout << "Thread run!" << endl;
});
try
{
do_something_else();
}
catch (...)
{
t.join();
throw;
}
t.join();
}
但是对于该问题还有一种更好的解决方案,RAII(资源获取即初始化)。其核心是把资源和对象的生命周期绑定,对象创建获取资源,对象销毁释放资源。因此,C++把底层的资源管理问题提升到了对象生命周期管理的更高层次。
class thread_guard {
public:
explicit thread_guard(thread &thread_):thread1(thread_) {}
~thread_guard() {
if(thread1.joinable()) {
thread1.join();
}
}
// 禁止使用guard对象进行初始化
thread_guard(const thread_guard&) = delete;
thread_guard& operator=(const thread_guard&) = delete;
private:
thread &thread1;
};
void func() {
thread thread1([] {
cout << "Thread run!" << endl;
});
thread_guard _thread_guard(thread1);
}
int main() {
func();
return 0;
}
创建一个线程保护类,对线程进行控制,在执行构造函数的时候进行资源的分配,在执行析构函数的时候,进行资源的释放。join
函数在调用时执行线程,函数返回时线程结束。
3.3 线程创建–传递参数
默认是会将传递的参数以拷贝的方式复制到线程空间。即使参数类型是引用类型。这里是指在线程启动的函数参数列表中是进行引用传值,会被默认修改。但是如果在创建thread
的时候,直接传递的就是引用值,那么是很危险的(前文)。原因是即使函数接受的是引用类型,不过在创建线程的时候,会构建一个线程空间,从线程空间中进行引用。
void func(int *a, int n) {}
int buffer[10];
thread t(func, buffer, 10);
t.join();
但是如果是要在执行线程中进行对象更新,结果是无法进行的。其引用的是拷贝对象到线程空间的对象,而不是初始化希望改变的对象。因此需要使用传引用的方式。
使用普通实例化对象创建一个线程。
thread t(&对象名.函数名,参数列表)
3.4 转让线程所有权
对于thread
,是可移动的(movable),但不可复制(copyable)。使用move
可以修改线程的控制权。
thread t1(f1);
thread t2(move(t1));
此时调用t1.join()
是错误的。可以通过get_id()
获取到线程的编号。
4 总结
本文仅仅只是对C++11中的线程的基本使用进行一个简单的说明,主要参考的文章是C++ 11多线程–线程管理。对于后面的博客会对多线程的两大核心问题进行更加深入的讲解。