本文详细介绍了多线程的常见概念 生产者消费者模型将在多线程(下)继续讲解
欢迎大家指正 提起讨论进步啊
目录
多线程的理解
线程的优点
线程的缺点:
线程的用途
线程VS进程
用户级线程库
POSIX线程库
线程创建:
线程等待
线程终止
取消线程
分离线程
Linux线程互斥
互斥量mutex
初始化互斥量
销毁互斥量
互斥量加锁和解锁
可重入VS线程安全
概念
常见的线程不安全的情况
常见不可重入的情况
常见可重入的情况
可重入与线程安全联系
可重入与线程安全区别
常见锁概念
死锁
Linux线程同步
条件变量
同步和竞态条件
为什么 pthread_cond_wait 需要互斥量
多线程的理解
线程(thread)是一个执行分支,执行粒度比进程更细,调度成本更低(不需要进行cache换),他是进程内部的一个执行流,同时是CPU调度的基本单位
进程是承担分配系统资源的基本实体
上面一堆话怎么理解呢?
首先明确一些知识:
寄存器分为两种:可见的和不可见的
CPU内部:运算器,寄存器,控制器,MMU,硬件cache,L1,L2,L3
cache是位于cpu和内存之间的存储器,读写速度高于内存而低于cpu内部的寄存器
由于数据的空间局部性原理和时间局部性原理,cache的引入提升了主存的读写效率
当cpu对某一内存地址发出读操作时,如果cache中已经缓存了此地址的数据,称为命中(hit),数据由cache直接发送给cpu。如果cache中并未缓存,则称为缺失(miss),数据将先由主存发送到cache,再由cache发送到cpu
硬件cache又称为高速缓存:缓存各种数据和代码,故增大了切换进程的成本
局部性原理/预加载:把一些热点数据提前加载到缓冲区
下面从linux操作系统角度来理解上面说的一堆线程概念
这是我们之前理解的进程,有自己的PCB,CPU中有维护这个PCB的寄存器,并且他对应着一个地址空间,进程有自己的PCB和代码数据
但是今天我们除了这一个PCB,还有更多的
每一个PCB我们可以称之为一个执行流,或者是所谓的线程,这些线程是指向同一块地址空间,所以他们可以看到一些共享数据,执行代码的角度来说,每一个PCB处理代码区中不同代码区域相当于一个进程有不同的执行流,那么线程就是进程内部的一个执行流,所以线程是一个执行分支,执行粒度比进程更细
之前在进程之间切换的时候要不停的切换cache中的数据,但是今天有了线程,我们可以看到同一份共享数据,cache不需要切换,所以调度成本变低
对CPU,他看到的还是PCB,只不过看到更多,每一次调度PCB的时候他是找线程而不是进程,所以线程是CPU调度的基本单位
而今天 进程的概念是这样的
不难理解,他是承担分配系统资源的基本实体
操作系统中一个线程对应着一个TCB(Thread Control Block),叫做线程控制模块,控制着线程的运行和调度,他属于进程PCB
Windows系统就是这样设计的,但是这个真的太复杂了,所以linux大佬想到了更好的方法:复用
复用你PCB的结构体 用进程PCB模拟线程的TCB
线程的优点
1.创建线程的代价要比创建进程的代价小得多
2.与进程之间的切换相比,线程的切换对于操作系统来说工作量小得多
3.线程占用的资源更少
4.充分利用多处理器的可并行数量
5.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
加密解密 文件压缩和解压等预算法有关的——比较消耗CPU资源
6.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
下载上传 IO主要消耗的是IO资源,磁盘的IO,网络带宽等等
误区:
线程可以比较多但是不是越多越好,具体多少是要量化的——保证进程/线程CPU的个数/核数一致
线程的缺点:
- 1.性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器
如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
- 2.健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
多线程中一个线程崩溃 最后会导致整个进程崩溃 为什么?
系统角度:线程是进程的执行分支 线程做就是进程做
信号角度:页表转换时MMU识别写入权限,没有验证通过
MMU异常——>OS识别——>给进程发信号——>linux进程信号 信号是以进程为主的
- 3.缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
因为执行流看到的资源是通过地址空间看到的 多个LWP看到的是同一个地址空间 所以所有的线程可能会共享进程的大部分资源 修改共享资源所有线程都能看到
- 4.编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程的用途
合理的使用多线程,能提高CPU密集型程序的执行效率
并且可以提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是
多线程运行的一种表现)
线程VS进程
线程私有:线程id 一组寄存器(有自己独立的上下文) 栈 errno 信号屏蔽字 调度优先级
共享:文件描述符表 每种信号的处理方式 当前工作目录 用户id和组id
用户级线程库
linux下没有在真正意义上的线程 而是用进程模拟的线程LWP 所以linux不会直接提供创建线程的系统调用 他会给我们最多提供创建轻量级进程的接口
用户视角:只认线程
用户级线程库:对下将linux轻量级接口封装 对上给用户提供进行线程控制的接口
用户级线程库是任何系统都要自带的也叫原生线程库
POSIX线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
要使用这些函数库,要通过引入头文<pthread.h>
链接这些线程函数库时要使用编译器命令的“-lpthread”选项
线程创建:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
thread:返回线程ID
attr:设置线程的属性,attr为nullptr表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事
前面讲的线程ID属于进程调度的范畴 因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的
线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
pthread_t 到底是什么类型呢?取决于实现 对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址
线程等待
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
创建新的线程不会复用刚才退出线程的地址空间
int pthread_join(pthread_t thread, void **value_ptr);
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED
3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
4. 如果对thread线程的终止状态不感兴趣,可以传nullptr给value_ ptr参数。
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return 这种方法对主线程不适用,从main函数return相当于调用exit
2. 线程可以调用pthread_ exit终止自己
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程
void pthread_exit(void *value_ptr);
value_ptr : value_ptr不要指向一个局部变量
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了
取消线程
int pthread_cancel(pthread_t thread);
返回值:成功返回0;失败返回错误码
分离线程
默认情况下,新创建的线程是joinable的(线程的属性),线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
pthread_detach(pthread_self());
joinable和分离是冲突的,一个线程不能既是joinable又是分离的——一个线程如果被分离 不能join
如果join会报错——join只是一个属性
Linux线程互斥
先明确一些概念:
临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量mutex
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互 多个线程并发的操作共享变量,会带来一些问题
(全局变量是共享资源 但是加上__thread修饰全局变量 变成__thread int g_val=100 就是每个线程各有一份 在线程局部存储!是线程内部的局部变量)
对全局变量-- 没有保护的话会存在并发访问的问题 进而导致数据不一致
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{char *id = (char *)arg;while (1){if (ticket > 0){usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;}else{break;}}
}
int main(void)
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);
}
结果是
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2
为什么可能无法获得争取结果?
if 语句判断条件为真以后,代码可以并发的切换到其他线程
usleep之后,可能有很多个线程会进入该代码段
--ticket 操作本身就不是一个原子操作
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
要解决的问题:
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁 Linux上提供的这把锁叫互斥量
初始化互斥量
静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
mutex:要初始化的互斥量
attr:nullptr
销毁互斥量
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化(静态分配)的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁
此时可以把上面的抢票代码修改一下
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 ) {
pthread_mutex_lock(&mutex);
if ( ticket > 0 ) {
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
// sched_yield(); 放弃CPU
} else {
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
}
可重入VS线程安全
概念
线程安全:保证多个线程并发同一段代码时,不会出现不同的结果
常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现线程安全的问题
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入
一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
常见的线程不安全的情况
- 不保护共享变量的函数
- 函数状态 随被调用 而发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都由函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
可重入函数是线程安全函数的一种,线程安全不一定是可重入的,而可重入函数则一定是线程安全的
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数的锁还未释放则会产生死锁,因此是不可重入的
常见锁概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态
死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
如何避免死锁:核心 破坏死锁的四个必要条件
不主动加锁(去掉互斥问题)
主动释放锁(破坏请求和等待)
按顺序申请锁
同一控制线程释放锁——剥夺锁
Linux线程同步
条件变量
条件变量是线程库提供的一个描述临界资源状态的变量 不用频繁的申请和释放锁也能检查到临界资源
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量
即 可以用条件变量实现线程同步
同步和竞态条件
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条 。在线程场景下,这种问题也不难理解
条件变量的初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
cond:要初始化的条件变量
attr:nullptr
销毁:
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量,后面详细解释
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
pthread_cond_signal——唤醒睡眠的线程,一次只能唤醒一个线程
pthread_cond_broadcast——唤醒睡眠的线程,一次唤醒所有睡眠的线程(所以叫广播)
为什么 pthread_cond_wait 需要互斥量
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护 没有互斥锁就无法安全的获取和修改共享数据、
所以条件变量和互斥锁总是一起使用
条件变量使用规范
等待条件代码
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号
pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);