Linux线程(线程的概念、线程的调度比进程快的原因、Linux 与 Windows 实现线程的不同策略、Linux的线程、C++ 11线程库、线程安全问题、线程的互斥、线程的同步、生产消费模型)
线程的概念、线程的调度比进程快的原因、Linux 与 Windows 实现线程的不同策略、Linux的线程、C++ 11线程库、线程安全问题、线程的互斥、线程的同步、生产消费模型
Linux线程(线程的概念、线程的调度比进程快的原因、Linux 与 Windows 实现线程的不同策略、Linux的线程、C++ 11线程库、线程安全问题、线程的互斥、线程的同步、生产消费模型)
1. 线程的概念及进程和线程的区别
1.1 线程的概念
在早期的操作系统中,唯一的执行实体是进程,每个进程都有独立的地址空间和资源,切换时需要保存和恢复大量状态信息,这使得进程切换的开销较大。随着对并发和响应速度要求的提高,在 20 世纪 60 年代,人们在操作系统中引入了进程的概念。线程被包含在进程之中,是进程中的实际运作单位。 即,进程和线程是包含和被包含的关系。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
线程是如何解决进程切换的开销较大的,在线程的速度比进程快的原因这里给出。
1.2 进程和线程的区别
进程和线程的区别主要有两个方面:
- 线程是 CPU 调度的基本单位,而进程(内核角度)是承担分配系统资源的基本实体。
- 线程和进程最大的区别是,线程之间共用一个地址空间,而进程的地址空间彼此独立。
进程和线程最大的区别,在于线程不具有独立性。这是因为同一个进程内的多线程共享一个地址空间,而地址空间具有拦截非法请求的功能。当程序因为非法操作被地址空间拦截非法请求时,操作系统不会鉴别是哪一个线程触发了非法操作,而是直接杀死使用这个地址空间的所有执行流。或者换一种角度理解,操作系统是从进程的角度进行程序的管理,杀死进行非法操作的进程,只是线程是包含在进程中的。
在同一个进程中的线程,并不是所有东西都共享的,线程也有自己独立的东西:
线程独立的:线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级
线程共享的:文件描述符表、每种信号的处理方式( SIG_IGN 、SIG_DFL 或者自定义的信号处理函数)、当前工作目录、用户 id 和组 id
1.3 线程的优缺点
1.3.1 优点
-
创建线程的成本低。
创建进程的成本比创建线程的成本高,因为创建进程意味着要创建PCB、地址空间、页表、构建映射关系……。而创建线程只需要创建PCB。
-
线程的调度成本低。
原因同线程的速度比进程快的原因
-
线程删除成本低。
删除进程要删除PCB、地址空间、页表、构建映射关系……,释放代码和数据。但删除线程只需要删除PCB。
-
线程占用的资源比进程少很多。
-
能充分利用多处理器的可并行数量。
-
在等待慢速I/O操作结束的同时,程序可以执行其他的计算任务。
-
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
-
I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待多个不同的I/O操作。
1.3.2 缺点
-
性能缺失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
-
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
-
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
-
线程不具有进程的独立性,当一个线程崩溃时,其他线程也会挂掉。
进程和线程崩溃时,OS都会向其发送终止信号。进程因为彼此独立,一个挂掉了也不影响另一个;但线程没有进程的独立性,收到终止信号后会一起被杀死。
合理的使用多线程,能提高CPU密集型程序的执行效率,提高IO密集型程序的用户体验。
1.4 线程id
pthread库提供给用户提供的线程 id 不是内核的轻量级进程 ,而是 pthread 库维护的一个值。由于 Linux 没有线程的概念,pthread 库就需要自己手搓一个线程属性集合( TCB ),它类似于进程的PCB,只不过PCB是由系统维护的,但 TCB 是 pthread 库维护的。
线程属性集合包含了struct_pthread、线程局部存储、线程栈等信息,所以线程的tid本质是一个指向线程属性集合的起始虚拟地址,在pthread 库中维护。
由于线程属性集合都是pthread动态库管理的,它在地址空间的位置位于堆栈之间的共享区。
线程在退出时,会将退出状态写入 struct_pthread
结构体中的某个位置(void ret),将除此之外的其他线程相关的属性都释放掉,等用户通过 pthread_join
函数获取退出状态,并释放资源*。所以说不使用 pthread_join
有可能导致内存泄漏,就是因为不通过 join 获取线程退出状态, struct_pthread
中对应的资源可能会一直保留不释放。
所以,Linux 线程可以认为是由pthread 库中线程的属性集和轻量级进程组成的。
2. 线程的调度比进程快的原因
线程之间的调度精度是微秒级的,而进程的调度精度是毫秒级的。CPU 中有一个硬件 cache,cache 存储着内存的热数据(使用频率高的内存块),CPU 在对热数据进行操作时,都是在调度 cache 里的数据。由于存在这个 cache ,进程和线程在时间片轮询时,上下文切换的开销存在显著差异:
- 地址空间切换开销: 进程有独立的地址空间(如果子进程触发写时拷贝),意味着进程在切换时,系统需要更新内存管理单元(MMU)的页表和刷新LTB(页表缓存)。这些操作可能会导致缓存命中率下降,从而带来额外的性能损失。
- 上下文数据量: 进程切换时需要保存和恢复的上下文信息更多,而线程只需要切换线程独立的信息(线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级),其他上下文数据 cache 共通。
- 内核数据结构和调度开销: 由于进程之间的资源隔离更严格,内核在调度时会进行更多的安全性和资源隔离检查,这也会增加切换时的开销。
- 缓存污染问题: 切换进程时,由于新的进程可能使用完全不同的内存区域,这可能会导致 CPU 的一级缓存和 TLB(页表缓存)中的数据失效。
3. Linux 与 Windows 实现线程的不同策略
Linux 和 Windows 操作系统之间,采取了完全不同的线程设计思路。Linux 通过进程来模拟线程。由于线程和进程的内容重复度高(都有独立的栈、CPU 调度都要切换上下文等), 所以 Linux 选择了复用进程 PCB ,用 PCB 统一表示执行流。因此,在Linux系统中,实际没有线程和进程的概念,Linux中的 CPU 也不区分进程和线程,统一视为执行流。Linux中的执行流被称为轻量级进程。Linux复用 PCB 模拟线程,优点是不需要为线程设计单独的数据结构和调度算法。
Windows 创建了独立的 TCB 结构体来创建线程,将线程和进程的概念从底层就完全区分开,这意味着 Windows 的进程和线程是独立的,线程有独立的管理线程 id 、优先级、状态、上下文、连接属性的数据结构,即 Windows 需要分别对进程和线程的代码进行维护,而 ,Windows 对于线程的维护成本要比Linux高。
Linux 不区分线程和进程的概念,使得它的线程相关的系统调用,是以 “轻量级进程” 的角度和逻辑来设计和使用的。但对于普通程序员来说,我们并不关心什么 “轻量级进程” ,只是想要使用线程来实现代码的并发。所以,为了解决这个问题,C 语言在 Linux 系统中提供了一个 pthread
库,这个库封装了 Linux 轻量级进程的系统调用,提供了以线程的角度和逻辑来使用轻量级进程的接口。但要注意,使用这个库时,必须要在编译命令上加上一段 -lpthread
,这是因为 pthread
库并不是系统库,但它是系统默认安装的库,它的绝对路径已经被系统环境变量保存下来了,使用时只需要告诉编译器要使用这个第三方库即可。
4.Linux的线程
Linux 没有线程的概念,只有轻量级进程,所以 Linux 只有轻量级进程的系统调用,用户要使用线程的接口,就需要对轻量级进程接口进行封装,按照线程的接口方式交给用户。所以,在 Linux 中可以说是用户级线程,Windows 是内核级线程。
Linux自带的原生线程库 pthread 库,但该库并非 Linux 系统的默认库,所以在编译时要指明:
gcc -lpthread
4.1 创建线程函数pthread_create()
这个函数用于创建一个线程。pthread_t
这个类型本质是 unsigned long int
,库对其进行了封装。
#include <pthread.h>
int pthread_create(pthread_t* thread,const pthread_attr_t* attr,void*(*start_routine)(void *),void* arg);
pthread_t* thread
:这是一个输出型参数,它会返回创建的线程id。const pthread_attr_t* attr
:用来设置线程属性。使用时设置为nullptr就可以,一般不需要改。void*(*start_routine)(void *)
:函数指针,线程要执行的函数的地址。void* arg
:这个参数会作为线程要执行的函数的参数。
reval
:成功返回0,失败返回错误码。
因为 void* arg
是 void*
类型参数,所以在传参的时候可以传递类类型的地址作为参数,即可以给线程传递多个参数、方法。
上面提到每个线程都有独立的栈,这种传参方式使得其他线程可以间接访问主线程的栈的数据,但由于线程谁先运行是不确定的,这些的操作有巨大的风险,即由于先后次序不确定,其他线程对主线程的栈上数据进行更改操作的时间也是不确定的。
所以在传递类类型作为参数的时候,一般会在堆上开空间(new一个对象),再把这个地址交给新线程,这样这个类类型对象就专属于新线程。
4.2 等待线程函数pthread_join()
主线程和 new
出来的新线程运行顺序是不确定的,有可能主线程运行结束了新线程还没开始运行,也有可能新线程运行结束了主线程还没结束,但我们一般期望主线程(main函数)是最后退出的。因为主线程退出,进程就结束了,其他线程还没执行完代码也会强制结束,所以需要在主线程里等待其他线程退出。
pthread_join
就是一个用于等待线程的函数,这个函数会阻塞等待特定的进程退出。且即使其他线程都退出了,main()
还没退出,不使用 join 会造成类似僵尸进程的问题。
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
pthread_t thread
:线程id
void **retval
:输出型参数,这个参数是获取线程返回值的地址,因为线程函数的返回值是void*
故这个参数的类型是void**
线程退出,只考虑正确退出,不考虑异常退出,因为异常了整个进程就崩溃了,所以对于 pthread_join
的返回值我们不需要考虑。
4.3 终止线程函数若干
进程终止的方法有多种,可以通过函数return返回,pthread_exit()
函数和 pthread_cancel()
函数。但切忌不能使用 exit()
函数退出。
pthread_exit()
是一个专门用来终止线程的函数,它用于线程自行调用退出。
void pthread_exit(void *retval);
pthread_cancel
用于在主线程取消其他线程,线程被取消退出结果是 -1
,本质是定义了一个宏PTHREAD_CANCELED ((void*)-1)
。
int pthread_cancel(pthread_t thread);
4.4 线程分离函数pthread_detach()
一个线程被创建默认是 joinable 的,且必须要 join 。但如果一个线程被分离(detach),线程的工作状态就变成分离状态,不需要被join 也不能被 join(被 join 会报错)。 线程分离运行,主线程脱离等待线程退出的阻塞状态,转而去执行其他操作。
int pthread_detach(pthread_t thread)
pthread_t thread
:传入线程的tid即可将线程变更为分离状态。
pthread_self()
这个函数用于线程获取自己的线程id,线程可以使用该函数自行分离。
pthread_t pthread_self(void);
5. C++ 11线程库
C++11 支持了多线程,使用 thread
类类型创建线程,但在编译时仍需要 -lpthread
,所以在 Linux 中,C++11的多线程本质就是对原生线程库接口的封装。C++11 这样做的目的,是为了将 Linux、Windows 和 macOS 中 C++ 使用多线程的方式统一,实际上都是对系统的原生线程库接口进行了封装。
所以 pthread 库创建线程,本质也是通过系统调用:
int clone(int (*fn)(void *), void *child_stack,int flags, void *arg, .../* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
这个函数是由 Linux 提供的创建轻量级进程的函数,它可以自定义设置栈的大小,存放的位置等信息,
pthread_create()
本质就是对其做了封装。
C++ 提供了 __pthread
关键字,它允许一个全局变量分别在线程之间私有,修改 __pthread
的变量互不影响,本质是编译器生成了两份数据,因为它们的地址并不相同。这种写法只在 Linux 下有效,且只适用于内置类型。
5.1 C++ 11线程的使用方法
完整内容 → C++11线程库
这里介绍 C++ 线程库中,线程的实例化方法。
C++11为 thread 类提供了无参构造和含可变参数构造两种主要初始化构造方式。删除了拷贝构造(毕竟线程可以拷贝不合理),提供了移动构造来配合无参构造使用。
#include<iostream>
#include<thread>
using namespace std;
void func(int n)
{
int x = 0;
for (int i = 0; i < n; i++)
{
++x;
}
cout << x << endl;
}
int main()
{
thread td1(func,100);
thread td2;
td2 = thread(func, 200);
td1.join();
td2.join();
return 0;
}
无参构造一般用于被数据结构创建并管理的情景:
#include<iostream>
#include<vector>
#include<thread>
#include<mutex>
using namespace std;
int main()
{
vector<thread> thd;
thd.resize(5);
mutex mtx;
int x = 0;
auto func = [&](int n) {
mtx.lock();
for (int i = 0; i < n; i++)
{
++x;
}
mtx.unlock();
};
for (auto& nthd : thd)
{
nthd = thread(func, 100);//移动构造
}
for (auto& nthd : thd)
{
nthd.join();
}
return 0;
}
6. 线程安全问题
6.1 原子性问题
由于线程有自己的栈,CPU在调度时它的寄存器需要保存线程的数据,在下一次调度时恢复数据。在某些场景下:
#include <thread>
#include <iostream>
#include <Windows.h>
using namespace std;
int num = 10;
void func()
{
while (true)
{
if (num > 0)
{
Sleep(1 / 1000);
num--;
}
else
{
break;
}
}
}
int main()
{
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << num;
return 0;
}
这这里
Sleep(1/1000);
是强行增加线程的运行时间,让线程每次++
前都阻塞 1 微秒。如果不加这一条,测试上万次可能也不会出现一次-1
。
CPU 中的 eax 寄存器负责进行逻辑运算和算数运算,并在时间片到期时保存运算的结果,假设在单核 CPU 中,程序运行到了这一刻:
- 此时 num == 1;
- 线程 A 获得时间片,进行
if (num > 0)
判断,结果为true
。- 线程 A 在访问
Sleep(1 / 1000);
过程中时间片到期。- 线程 B 获得时间片,进行
if (num > 0)
判断,结果为true
。- 线程 B 访问
Sleep(1 / 1000);
后时间片没到期,进行num--;
操作,此时num == 0
;- 线程 B 进行
if (num > 0)
判断,结果为false
退出循环。- 线程 A 获得时间片,CPU 访问
eax
发现逻辑判断为真(即线程 A 在先前时间片中进行if (num > 0)
判断,结果为true
),进行num--;
操作,此时num == -1
。- 线程 A 进行
if (num > 0)
判断,结果为false
退出循环。
注意,单核 CPU 是为了简化讨论,实际上面的测试截图就是在家用笔记本电脑的多核 CPU 中测试出来的。
6.2 可见性问题
两个线程,分别对 num
进行 ++
操作 50000 50000 50000 次,结果应该是 > 100000 >100000 >100000 、 = 100000 =100000 =100000 还是 < 100000 <100000 <100000 ?
#include <thread>
#include <iostream>
#include <Windows.h>
using namespace std;
int num = 0;
void func()
{
for (int i = 0; i < 50000; i++)
{
Sleep(1/1000);
num++;
}
}
int main()
{
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << num;
return 0;
}
这里
Sleep(1/1000);
是强行增加线程的运行时间,让线程每次++
前都阻塞 1 微秒。如果不阻塞这 1 微秒,实际在我测试下来,使用比较贵的家用笔记本,即使是亿级的数据也没有出现线程安全问题,但是在只有两核的云服务器上,数据在 4000 4000 4000 左右就会 100 % 100\% 100% 出现线程安全问题。
实际运行结果总是小于十万,这是因为,num++
这个操作并不具有原子性。所谓的原子性,就是一个事件要么是做了,要么是没做,没有做到一半的中间状态,那么这个事件就具有原子性,相关操作为原子操作。也就是说,加一这个操作,除了没加一和加一两个状态之外,还有一个加到一半的中间状态。
num++
这条操作看似简单,实际上在汇编中执行时是一个复合操作,包含了 读取、加 1、写回 三个步骤。意味着在多线程环境中,一个线程在执行这三个步骤的过程中,另一个线程可能会中断并且修改共享变量 num
,从而导致数据不一致,如:
- 假设某时刻 num == 1;
- 线程 A 此时到来,读取 num == 1;
- 线程 A 进行 +1 操作;
- 线程 B 此时到来,读取 num == 1;
- 线程 A 对 num 的值进行写回,此时 num == 2;
- 线程 B 进行 +1 操作;
- 线程 B 对 num 的值进行写回,此时 num == 2;
这种竞争条件(race condition)导致了数据不一致,所以结果总是小于十万。
注意,也许你在 VS 中对 num++ 这个操作进行反汇编,会看到自增的汇编就是一条(如
002F1024 inc dword ptr [num (02F5434h)]
)但这个汇编指令实际上是可以被展开成多条汇编指令的,所以这条指令也不是原子的。
6.3 有序性问题
有序性问题就是多线程执行的顺序与代码中它们实例化的先后顺序无关,可以看作是随机的(由于编译器和处理器可能会对指令进行重排序,使得实际执行顺序与代码顺序不一致),导致需要有先后顺序的代码可能会出错:
#include <iostream>
#include <thread>
// 全局共享变量
char a = 'a';
char b = 'b';
bool flag = false;
// 写线程:更改标志
void writer()
{
flag = true;
}
// 读线程:根据标志判断读取哪个
void reader()
{
if (flag)
{
std::cout << "a = " << a << std::endl;
}
else
{
std::cout << "b = " << b << std::endl;
}
}
int main() {
std::thread t1(writer);
std::thread t2(reader);
t1.join();
t2.join();
return 0;
}
7. 线程的互斥
7.1 互斥的原理
由于线程存在线程安全问题,于是引入了互斥(mutex)这个概念来解决线程不安全。线程不安全的原因在于并发,虽然我们使用线程就是希望一个程序的不同位置的代码能够并发运行,但是在一个程序中往往有一部分代码我们不希望这里并发运行。所以,互斥解决线程不安全的方法就是,让程序在需要并发的地方并发,在不需要并发的地方单独运行,具体来说,互斥通过设立临界区来保护临界资源,实现保证线程安全。
7.2 临界资源和临界区
临界资源是指多个线程可能同时访问并修改的数据,而临界区是每个线程内部,访问临界区的代码。互斥就是对临界资源进行保护,即在任何时刻,只允许一个线程进行资源访问。
例如在上述线程不安全的例子中,由于 num
是所有线程能够共同访问的资源,故其是临界资源;而 func()
是所有线程都共同访问的、对 num
进行修改的方法,故其是临界区(注意这里临界区的范围不唯一):
在代码中划分临界区,需要使用互斥锁。
7.3 互斥锁
C++ 线程库的互斥锁接口详细,请看 → C++11线程库 。
互斥锁在 C++ 线程库中是一个类对象,它持有一把唯一的锁,只有第一个访问这个互斥锁的线程能够获得这个锁然后继续执行下面的代码。当有第二个线程访问这个互斥锁时,由于锁已经被第一个线程拿走,第二个线程只能够被阻塞在互斥锁这里,等待第一个线程使用完锁后将锁和还给互斥锁。接着第二个线程及后续到来一起阻塞在互斥锁上的线程,重新争夺互斥锁,同样是获得锁的能够往下执行,没有锁的继续被阻塞。
形象地描述,临界区就是一个 “独木桥” ,而互斥锁是 “独木桥” 的入口,独木桥上只能同时有一个线程在通过,其他线程在 “独木桥” 上的线程没到达另一端时,只能在入口等待。
例如,我们按照上面划分的临界区设置互斥锁,效果就为:
结果也能保证是线程安全的:
7.4 pthread库互斥锁接口
pthread 库中提供了互斥锁来保护资源,其中 pthread_mutex_t
是互斥锁类型,使用该类型实例化一个互斥锁即可用于保护资源。pthread 库也提供一些用于互斥锁的库函数。需要注意的是,如果互斥锁是全局或静态的,只需要对其 init,不需要 destroy 。
7.4.1 销毁互斥锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
7.4.2 初始化锁
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
第一个参数传入锁的地址,第二个参数attr指定了新建互斥锁的属性,若为空则使用默认的互斥锁属性,默认属性为快速互斥锁 。
定义时初始化:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
7.4.3 加锁解锁相关
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
7.4.4 使用方法
-
使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
语句创建一个名为mutex(或其他)的互斥锁。 -
在需要保护的临界区(代码段)使用
pthread_mutex_lock()
函数加锁。 -
在临界区(代码段)结束行使用
pthread_mutex_unlock()
函数解锁。
7.5 临界区的设计
如果在测试时使用的设备性能较差、测试的数值较高。我们就会明显感觉到线程之间设置互斥锁后,被锁的代码段在执行时明显速度会下降。这是因为互斥锁只允许一个线程拥有访问临界区的权限,其他的线程虽然时间片轮询到它们,由于这些线程没有成功申请锁,它们会被 lock()
(或 pthread 库的pthread_mutex_lock()
) 阻塞,直到拥有锁的线程解锁成功。
我们可以更换为云服务器,观察不同临界区设计的差异。这里我们需要使用到 C 语言提供的 clock()
,这个函数的返回值是当前程序运行到 clock()
时消耗的时间,我们可以通过这个函数观察不同临界区设立的时间损耗:
注意:
- 这里不设立临界区的程序运行速度比临界区在循环外的程序运行速度慢是因为,操作系统发现一个线程是处于被阻塞的状态后,是不会让它把自己的时间片跑完才切换的,而是立即切换给其他不阻塞的线程使用。所以临界区在循环外的情况中,获得锁的线程能更快地把自己的 50000 50000 50000 次自增操作算完,程序更快地结束。
- 在 VS 上测试时,可能由于家用笔记本 CPU 性能太好或编译器进行优化,测试不出三种代码明显的耗时差异。
可以看到,临界区在循环内的代码,其耗时所需的时间差其他两种几个数量级。因此,在实际的开发中,合理地使用互斥锁非常重要,即,在使用锁时,加锁的范围、粒度一定要尽量小。
7.6 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。最常见的情况是,有一个线程获得锁并执行完临界区代码后,没有归还锁,导致其他线程被阻塞在互斥锁上,程序不能正常结束陷入死循环。
死锁四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁:
破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配
7.7 锁守卫lock_guard
C++ 早期引入了异常处理,这种机制虽然能够使得程序异常能够更方便地被处理,但由于其触发异常后,执行流的代码执行顺序十分不可预见,对设计避免死锁的难度大大增加。
为了更方便的避免死锁和弥补异常处理的缺点,C++ 引入了另一个机制,锁守卫(lock_guard) 。锁守卫是一个类对象,能够在一定情况下让线程自动释放锁,其实原理很简单,C++ 利用了析构函数来实现这个功能,只要我们将临界区放在一个函数中,执行流如果因为异常处理跳出函数栈,就会触发 lock_guard 的析构函数(因为 lock_guard 会设计为该函数栈的局部变量),通过析构函数执行锁的 unlock()
函数来释放锁资源。
#include <thread>
#include <iostream>
#include <string>
#include <Windows.h>
#include <mutex>
using namespace std;
mutex mtx;
void func(string thread_name)
{
lock_guard<mutex> lg(mtx);
if (thread_name == "t1")
{
throw(thread_name);
}
cout << "现在获得锁的是" << thread_name << endl;
}
void deal(string thread_name)
{
try
{
func(thread_name);
}
catch (string thread_name)
{
cout << "抛出" << thread_name << endl;
}
}
int main()
{
thread t1(deal, "t1");
thread t2(deal, "t2");
t1.join();
t2.join();
return 0;
}
7.8 加锁解锁的原理
所有线程申请锁,前提是所有的线程都能看到锁,即锁本身也是共享资源,那么,加锁的过程也必须是原子的。实现互斥锁是通过系统提供的 swap 或 exchange 指令,将寄存器和内存单元的数据进行交换,该指令由于只有一条,故具有原子性。
加锁:
现令每个线程的寄存器中上下文数据初始化为 0
,线程在加锁的过程中,通过调用 swap 或 exchange 指令将内存中的 1
交换过来,判断只有寄存器内为 1
的线程上下文为申请锁成功,其他线程由于内存中的值为 0,交换后寄存器的值也为 0,只能阻塞等待。
解锁:
将寄存器上下文含有1的线程与内存单元数据交换,此时CPU中相关位置的值又回到最初的状态,即实现解锁。
lock:
move $0, &al
xchgb %al, mutex
if(al寄存器的值>0)
{
return 0;
}
else
挂起等待;
unlock:
move $1, mutex
唤起等待Mutex的线程;
return 0;
9. 线程的同步
同步,即在保证临界资源安全的前提下,让执行流访问临界资源有一定的顺序。
假设现有两个线程向同一份临界资源里读写数据。A线程向临界资源拿数据,B线程向临界资源放数据,两个线程竞争同一把锁。若其中一方竞争锁的能力大于另一方,如A总是能申请到锁,所以B线程没有机会向临界资源里放数据,但A总是要访问临界资源才能知道里面是否有数据,于是就造成了B线程的饥饿问题。对于这种情景,我们希望线程能按照一定的顺序访问临界资源(申请到锁),于是就需要条件变量来控制。
9.1 条件变量
9.1.1 条件变量介绍
条件变量(Condition Variable) 是一种线程同步机制,它利用多线程间共享条件变量(全局)进行线程的同步控制,通常要配合互斥锁使用。条件变量的同步逻辑是:
当线程 A 调用 pthread_cond_wait()
会先检查共享状态是否满足,如果不满足则进入等待队列(一般需要程序员自己设计满足条件),当线程 B 调用pthread_cond_signal()
或 pthread_cond_broadcast()
时会唤醒在 pthread_cond_wait()
中等待的线程。
注意,在使用条件变量的接口时,无论是等待接口还是唤醒接口,都必须设置在临界区中保证线程安全。
9.1.2 条件变量接口
用于静态初始化条件变量的宏:
PTHREAD_COND_INITIALIZER;
用于初始化条件变量:
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);
pthread_condattr_t: 是控制条件变量属性的参数,实际传入 NULL 使用系统默认的属性即可。
用于其他等待条件变量满足:
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
注意,线程在调用这个函数时,除了让自己排队等待,还会释放自己传入的锁。返回时,必须先参与锁的竞争,重新上锁才会返回。
用于销毁条件变量:
int pthread_cond_destroy(pthread_cond_t *cond);
销毁条件变量,在程序运行结束前调用。
用于广播唤醒等待队列中的所有线程。:
int pthread_cond_broadcast(pthread_cond_t *cond);
只会唤醒等待队列中的一个线程:
int pthread_cond_signal(pthread_cond_t *cond);
9.1.3 条件变量的使用
在 C 语言中,条件变量的类型是 pthread_cond_t
,使用时,需要把条件变量设置为全局变量(否则没有意义)。条件变量有静态和动态两种初始化方式:
pthread_cond_t con = PTHREAD_COND_INITIALIZER; //静态初始化
pthread_cond_t con; //动态初始化
pthread_cond_init(&con,NULL);
每个 pthread_cond_t
都有自己的条件变量等待队列,所以,唤醒操作也仅针对当前条件变量的等待队列生效。 在实际编码中,往往会存在多个条件变量,一般与线程扮演的角色种类的数量相关。如生产消费模型中,生就应当分别有生产者的条件变量和消费者的条件变量。
9.2 信号量
信号量(Semaphore)是一种用于进程间或线程间同步的机制,主要用来解决共享资源的访问控制问题。信号量是一种资源的预定机制(预定,即在外部,可以不判断满足资源是否满足条件,就可以知道资源内部的情况),信号量的本质是一个计数器,其值表示当前可用资源的数量,使用信号量作为计数器是因为其提供的增减操作接口皆为原子操作,增操作称为 P 操作,减操作称为 V 操作。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
pshared:
0
表示用于线程同步,1
表示用于进程间同步。value: 信号量的数量。
reval: 成功返回
0
,失败返回-1
。
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem);
表示 V 操作,如果信号量不为
0
就--1
且是原子操作;如果信号量为0
就阻塞直到信号量大于0
,再--1
。
int sem_post(sem_t *sem);
表示 P 操作,将信号量
++1
且为原子操作,如果有线程在sem_wait()
中阻塞,就唤醒其中一个线程。
10. 生产消费模型
通过生产消费模型,能更好地理解线程的同步 → Linux环形队列实现生产消费模型
11. 读写者模型及读写者锁
读写者模型是另一种多线程模型,它的特点是读者多且读操作频率高、写者少且写操作频率低,因此,读写者模型认为线程饥饿问题并不是需要解决的 BUG ,而是模型的特点 → Linux读者写者模型及读写锁
12. 相关命令
查看运行中的线程的命令
ps -aL #LWP表示light weight process 轻量级进程,LWP等于PID的线程是主线程
查看CPU属性的指令
lscup
*13. 可重入与线程安全
13.1 可重入与线程安全概念
线程安全:
多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函函数.。
结论:
可重入的函数是线程安全的,但线程安全的函数不一定可重入。
13.2 常见的可重入与线程安全的情况
常见的线程不安全的情况:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数。
常见的线程安全的情况:
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
类或者接口对于线程来说都是原子操作。
多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见不可重入的情况:
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的。
调用了标准 I/0 库函数,标准 I/0 库的很多实现都以木可重入的方式使用全局数据结构。
可重入函数体内使用了静态的数据结构。
常见可重入的情况:
不使用全局变量或静态变量。
不使用 malloc 或者new 开辟出的空间。
不调用不可重入函数。
不返回静态或全局数据,所有数据都有函数的调用者提供。
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。
13.3 可重入与线程安全联系与联系
可重入与线程安全联系:
函数是可重入的,那就是线程安全的。
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别:
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
更多推荐
所有评论(0)