C++多线程初探:thread、atomic及mutex的配合使用

  • 线程及进程关系
  • 线程同步与线程安全
  • 用atomic/mutex实现线程安全
  • join和detach概念
    总结了这段时间看的线程和进程相关内容,并且用C++相关库做了对应实现。
    才疏学浅,理解不到位的地方,欢迎指正。

不同OS下的多线程方案

C/C++的多线程编程在啊不同操作系统下有不同函数库可以进行调用,在Linux平台下,POSIX多线程库是,在windows平台下,有windows API或MFC库(VC对WIN32 API的封装),但是为了多线程程序在不同编译系统下的可移植性好,选用c++的库是比较合适/简便的。

线程相关的一些概念的说明

进程与线程的关系

进程是程序分配资源的最小单位,而线程是程序执行的最小单位(实际CPU时间就是分配给各线程的),简要来说,一个进程中可以有多个线程,而这些线程会共用进程中的资源(包括打开的文件、信号标识及动态分配的内存等)和地址空间,而每个执行的线程还会有自己的堆栈区和CPU寄存器状态。

线程的执行顺序和是否可执行是由系统的调度程序决定的,线程有优先级别,优先级别高的程序优先执行(但是同时也要维护进程执行时间分配的公平性)。

在多处理器的机器上,调度程序可以将多个线程放到不同的处理器上运行,这样可以使处理器任务平衡,并提高系统的运行效率。

在进程存在的基础上,我们还需要有线程存在的原因是:

  1. 进程之间的通信(IPC方式包括有名/匿名管道、信号、信号量、消息队列、共享内存、套接字等)并不算方便,而进程之间因为是共享线程的资源(包括全局/静态变量)因此通信会比较方便,只是在多个线程需要访问(写)同一个变量时,需要注意线程同步(在查找值和改写值之间不中断)的问题。
  2. 进程的开启和销毁的代价是远大于线程的,当然线程的开启和销毁也是有代价的,因此有线程池的出现(所谓线程池就是将开辟多个线程,维护一个线程池,每次需要新线程时,将从中选取空闲线程,线程用完之后再放回线程池)。

线程的同步与线程安全

线程同步是同一时刻对公共变量的值的认知需要保持一致(不会线程1以为公共变量是1,而线程2以为公共变量是2),如果确保在多线程编程环境中,线程能够正确地处理多个线程之间的共享变量,就称为线程安全

线程之间共享的变量通常需要注意线程安全的问题,即保证多个线程之间访问同一个变量,不会出现一个线程对公共变量的读写被其他线程打乱的情形(此时需要保证该线程对关键区的访问为原子操作,禁止中断)。

因此通常需要加锁(加锁对象是需要访问公共变量的代码段,我们叫做临界区critical section)也就是常见的mutex(mutual exclusion),保证一个线程对公共变量进行读写操作之间不会被其他线程影响,等到Unlock mutex时变量才可以被其他线程访问。

举个在多线程环境中公共变量访问出错的例子

  • 输出说明:预期结果是2e6,但是实际结果明显小于预期结果
  • 产生原因:实际++n的执行过程仍然是n=n+1,这涉及了两步操作,一是读取n,计算n+1;二是将n+1的结果赋值给n。多线程在这里的影响就是一个线程在n+1的结果赋值给n之前,另一个线程在原来n的基础上做+1的计算,而不是在+1之后的结果上做+1,因此最终的结果会小于预期。
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<thread>
#include<iostream>
using namespace std;

const int N=1e6;
int n;

void increase_n()
{
for(int i=0;i<N;i++)
{
++n;
}
}

int main()
{
thread t1(increase_n);
thread t2(increase_n);

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

cout<<n<<endl;
return 0;
}

C++中的解决办法

具体在C++中实现时:

  • 为了避免编译器对变量做不必要的优化,通常需要将公共变量声明为易失性变量volatile,使得线程每次访问该变量时需要从内存中读取即时值,而不能从线程自己的寄存器中读取暂存值,以保证线程间的同步。关于volatile是为多线程准备的,这点是有争议的(因此这点暂放)。
  • 对访问公共变量的临界区代码我们可以使用mutex锁,保证在mutex的lock加锁和unlock之间执行临界区代码时不产生中断。
    按照mutex加锁,我们可以得到方案1:mutex锁保证线程安全解决上述例子中的问题。
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
//方案1:mutex锁保证线程安全
#include<thread>
#include<iostream>
#include<mutex>
using namespace std;

mutex m;
const int N=1e6;
int n;

void increase_n()
{
for(int i=0;i<N;i++)
{
m.lock();
++n;
m.unlock();
}
}

int main()
{
thread t1(increase_n);
thread t2(increase_n);

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

cout<<n<<endl;
return 0;
}
  • 对于某些简单的操作,使用mutex来保证线程安全会比较昂贵,因此可以使用原子变量(对于原子对象,即如果一个线程对原子对象进行写,另一个线程对对象进行读时,行为是明确的;另外一方面,当获取原子变量的值时会确立线程同步)来提供一个可使函数既线程安全又可重入(概念解释如下)的方案
    • 编译器在编译阶段通常会对我们的代码进行优化(如乱序执行、指令重排等)以追求更高的效率,而这种操作在多线程环境下,通常会引发很多问题。而定义的原子操作就涉及对这些方面的说明,包含我们三个方面关心的语义:1. 操作本身是不可分割的(Atomicity),一个线程对某个数据的访问何时对另外一个线程可见(visibility),执行的顺序是否可以重排(Ordering)。
    • 可重入的程序是,在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又执行了该程序,但却不出错。可重入的函数应当满足,所有变量都保存在调用栈的当前函数栈/帧(frame)上,因此同一执行线程重入执行该函数时加载了新的函数帧,与前一次执行函数使用的函数帧不冲突。
      • 函数帧:又叫栈帧,每一次函数调用都会产生一个函数帧,每个栈帧对应一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量,记录着函数的执行环境。

运用原子变量,我们就得到了方案2:用原子变量来保证线程安全

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
//方案2:用原子变量来保证线程安全
#include<thread>
#include<iostream>
#include<atomic>
using namespace std;

const int N=1e6;
atomic<int> n(0);

void increase_n()
{
for(int i=0;i<N;i++)
{
++n;
}
}

int main()
{
thread t1(increase_n);
thread t2(increase_n);

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

cout<<n<<endl;
return 0;
}

joindetach

上述例子中已经用到了joindetach,以下对两者进行说明。
对于C++程序而言,你的main函数就是你的主线程,主线程在任何需要的时候都可以创建新的线程,当线程执行完毕时,自动终止线程;当进程结束时,所有线程都必须终止。

  • 所谓join就是主线程在调用join方法的位置等待子线程会合,会合之后执行下一步操作。
  • 所谓detach就是,主线程将不等待子线程的会合,自己直接结束生命,子线程未执行完也不报错,会自己在执行完毕后会退出。

如果某个线程,不声明以上两者之一,但是在主线程结束之前又没有执行完毕,程序执行时会崩溃。

参考资料

文章目录
  1. 1. 不同OS下的多线程方案
  2. 2. 线程相关的一些概念的说明
    1. 2.1. 进程与线程的关系
    2. 2.2. 线程的同步与线程安全
      1. 2.2.1. 举个在多线程环境中公共变量访问出错的例子
      2. 2.2.2. C++中的解决办法
    3. 2.3. join及detach
  3. 3. 参考资料
|