【操作系统】线程理解 +POSIX线程库 + 线程互斥 + 可重入VS线程安全
【操作系统】线程理解 +POSIX线程库 + 线程互斥 + 可重入VS线程安全
- Linux线程概念
- 线程的优点
- 线程的缺点
- 线程异常
- 线程用途
- Linux进程VS线程
- POSIX线程库
- 创建线程:pthread_create
- 线程终止:pthread_exit(void *)终止自己,pthread_cancel(pthread_t )终止同一进程中的另一个线程
- 线程等待:pthread_join(pthread_t , void **) :手动是否资源#二级指针
- 分离线程:pthread_detach(pthread_t ):线程退出,自动释放线程资源
- Linux线程互斥
- 互斥量mutex
- 互斥量的接口
- 初始化互斥量:动态分配,静态宏
- 销毁互斥量:pthread_mutex_destroy(pthread_mutex_t * )
- 互斥量加锁和解锁:pthread_mutex_lock / pthread_mutex_unlock(pthread_mutex_t * )
- 可重入VS线程安全
- 常见的线程不安全情况
- 常见的线程安全的情况
- 常见不可重入的情况
- 常见可重入的情况
- 可重入与线程安全联系
- 可重入与线程安全区别
Linux线程概念
线程又叫轻量级进程
一切进程至少都有一个执行线程
线程在进程内部运行,本质是在进程地址空间内运行
进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
线程的优点
创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
一句话总结:线程相比进程创建代价小、切换工作少、占用资源少,能充分利用多处理器并行能力,且可在等待I/O时执行其他任务,适合将计算密集型任务分解并行、让I/O密集型任务重叠以提升性能
线程的缺点
性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
同步和调度开销,而可用的资源不变
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了
不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
一句话总结:多线程存在性能损失(计算密集型线程过多增加同步调度开销)、健壮性降低(线程间缺乏保护易因偏差或共享变量出问题)、缺乏访问控制(线程操作影响整个进程)、编程难度提高(编写调试更复杂)的问题
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
一句话总结:单个线程因除零、野指针等问题崩溃时,会触发信号机制导致所属进程终止,进程内所有线程也会随之退出
线程用途
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现
一句话总结:合理使用多线程,既能提升CPU密集型程序的执行效率,也能改善IO密集型程序的用户体验(如同时写代码和下载工具的场景)
Linux进程VS线程
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器
栈
errno
信号屏蔽字
调度优先级
进程的多个线程共享 同一地址空间,数据段Text Segment、代码段Data Segment都是共享的
线程还共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
POSIX线程库
POSIX线程库中,绝大多数线程相关函数以“pthread_”开头,使用时需引入头文件`<pthread.h>,且编译链接时要加“-lpthread”选项以关联线程函数库
创建线程:pthread_create
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(start_routine)(void), void *arg)
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
参数:
//第一个参数:为输出参数,进程内唯一 的线程标识符,不同进程的 tid 可能重复,它指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID
//第二个参数:nullptr:线程默认属性
//第三个参数:线程入口函数,新线程启动后会自动执行的函数,threadRun函数原型必须是 void ()(void)*(接收 void* 参数,返回 void*)
//第四个参数:传递给入口函数的参数
// 在当前进程的地址空间中,创建一个新的可并发执行轻量级进程(线程),新线程会从指定的函数开始执行, 与主线程(或其他已存在线程)共享进程的资源(如内存、文件描述符等),但拥有独立的栈空间和线程上下文(如程序计数器、寄存器)
//调用pthread_create 成功后,当前线程(如主线程)会继续执行后续代码,新线程会从 线程入口函数 的第一行开始执行(两个线程并发执行,
//调度由操作系统内核决定,无法预测执行顺序//若主线程在新线程执行完前退出(如主线程没有 pthread_join 等待),且新线程未设置为 “分离状态”,新线程会变成
僵尸线程(资源无法回收);
//若主线程是进程的最后一个线程,进程会退出,所有未执行完的线程也会被强制终止
Linux下,线程ID的类型:pthread_t,本质就是一个进程地址空间上的一个地址
TCB:线程控制块
pthread_t pthread_self(void); # 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID
线程终止:pthread_exit(void *)终止自己,pthread_cancel(pthread_t )终止同一进程中的另一个线程
只终止某个线程而不终止整个进程:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit
- 线程可以调用pthread_ exit终止自己
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程
void pthread_exit(void *value_ptr); # 线程终止
value_ptr不要指向一个局部变量
pthread_exit或线程函数return返回的指针,指向的内存必须是全局的或malloc分配的,不能是线程函数栈上的内存——因为线程函数退出后栈内存会失效,其他线程拿到该指针时会访问非法内存
int pthread_cancel(pthread_t thread); # 取消一个执行中的线程
参数
thread:线程ID
线程等待:pthread_join(pthread_t , void **) :手动是否资源#二级指针
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
创建新的线程不会复用刚才退出线程的地址空间
int pthread_join(pthread_t thread, void **value_ptr); # 调用该函数的线程将阻塞等待,直到id为thread的线程终止
参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,
总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数
PTHREAD_ CANCELED - 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参
数 - 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数
分离线程:pthread_detach(pthread_t ):线程退出,自动释放线程资源
默认情况下,新创建的线程是joinable(可连接状态),线程退出后,需要对其进行pthread_join操作,否则无法释放
资源,从而造成系统泄漏
如果不关心线程的返回值,当线程退出时,自动释放线程资源->分离线程
int pthread_detach(pthread_t thread);
pthread_detach(pthread_self()); # 线程自己分离
Linux线程互斥
临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界资源的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
互斥量mutex
线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,变量归属单个线程,其他线程无法获得这种变量
变量需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互
多个线程并发的操作共享变量,会带来一些问题
为什么多线程访问共享变量会有问题?
代码可以并发的切换到其他线程
对共享变量的++,- -等操作并非原子操作,通常会分解为“读取变量值→修改值→写回变量”三条汇编指令。若执行过程中CPU时间片耗尽并切换到其他线程,其他线程可能读取到未完成修改的中间值,导致共享变量操作混乱(如计数错误)
解决以上问题:需要一把锁同时做到以下三点,Linux上提供的这把锁叫互斥量
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
互斥量的接口
初始化互斥量:动态分配,静态宏
初始化互斥量有两种方法:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER # 静态分配,宏初始化
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); # 动态分配
参数:
mutex:要初始化的互斥量
arrtr:指定互斥锁的属性(如共享属性、类型等),传 NULL 表示使用默认属性
销毁互斥量:pthread_mutex_destroy(pthread_mutex_t * )
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁和解锁:pthread_mutex_lock / pthread_mutex_unlock(pthread_mutex_t * )
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_mutex_lock 时可能遇到以下情况:
- 互斥锁未被占用:当前线程成功获取锁,继续执行临界区代码。
- 互斥锁已被其他线程占用:当前线程进入阻塞状态,等待锁被释放后重新竞争获取。
- 互斥锁已被当前线程占用(非递归锁):导致死锁(线程自身阻塞等待自己释放的锁)。
- 互斥锁无效或已销毁:返回错误码(如 EINVAL),无法正常加锁
可重入VS线程安全
线程安全:线程安全指多个线程并发执行同一段代码时,始终能得到正确、一致的结果;
若代码中对全局变量 / 静态变量操作且无锁(如互斥锁)等同步机制保护,会因多线程争抢资源导致操作非原子化,进而出现结果错误(如计数偏差、数据错乱),此时代码就是线程不安全
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,称之为重入
一个函数在重入的情况下,运行结果不会出现任何问题 -> 可重入函数,否则,是不可重入函数
常见的线程不安全情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
// 带有静态变量(内部状态)的函数
int counter() {static int num = 0; // 静态变量,函数调用结束后不销毁num++; // 每次调用修改状态return num;
}
第一次调用返回 1,第二次返回 2,第三次返回 3……
状态(num 的值)随每次调用递增,后续调用的结果依赖于前一次的状态
- 返回指向静态变量指针的函数
在多线程环境中,若多个线程同时调用并操作该静态变量,容易因缺乏同步机制导致数据错乱(线程不安全)
若两个线程同时调用该函数修改静态变量,可能出现 “线程 A 读取值→线程 B 覆盖值→线程 A 写回错误值” 的情(非原子操作导致
// 返回指向静态变量的指针
int* getStaticVal() {static int val = 0;val++;return &val; // 返回静态变量的地址
}
多次调用此函数,返回的指针始终指向同一个val,其值会持续递增
- 调用线程不安全函数的函数
常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
常见可重入的情况
不使用全局变量或静态变量
不使用用malloc或者new开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果重入函数时,若锁还未释放则会产生死锁,因此是不可重入的