线程
- 1. 概念
- 2. 库函数
- 线程库
- 创建线程
- 线程ID
- 线程终止
- 线程等待
- 线程分离
- 3. 线程的互斥
- 相关概念
- 临界资源
- 互斥量 - mutex
- 初始化互斥量
- 静态分配
- 动态分配
- 销毁互斥量
- 互斥量加锁
- 互斥量解锁
- 死锁
- 概念
- 死锁的四个必要条件
- 避免死锁
- 避免死锁算法
- 4. 线程的同步
- 条件变量
- 初始化条件变量
- 静态分配
- 动态分配
- 销毁条件变量
- 等待条件满足
- 唤醒等待
- 5. 线程的优点
- 6. 线程缺点
- 7. 线程异常
- 8. 线程用途
- 9. 一些问题
- 已经有多进程了,为什么要有多线程?
- 为什么线程的调度成本低?
- 关于线程的私有部分
- 关于线程和寄存器
- CPU寄存器只有一套,寄存器里面的数据可以有多套
1. 概念
- 一个程序里的一个执行路线就叫做线程,线程是一个进程内部的控制序列(执行流)
- 线程是CPU调度的基本单位,进程是承担分配系统资源的基本实体
- 线程有线程id,优先级,状态,上下文,连接属性等
- 一个进程多个线程属于同一个pid,但每个线程都有自己的LWP
- Linux下使用进程模拟线程(CPU不需要区分看到的task_struct是进程还是线程,都属于执行流(轻量级进程))
ps -aL #查看线程情况
2. 库函数
线程库
- 调用线程库需要引入头文件<pthread.h>
- 链接这些线程函数库要使用编译器命令 -lpthread 选项
创建线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(start_routine)(void), void *arg);
- 功能:创建一个新的线程
- thread:输出型参数,用于接受创建完的线程ID
- attr:设置线程的属性,attr为NULL表示使用默认属性
- start_routine:是个函数地址,线程启动后要执行的函数,这个函数必须返回值为void,参数为void**
- arg:传给线程启动函数的参数
- 返回值:成功0,失败错误码
关于返回错误:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
- pthreads函数出错时不会设置全局变量errno,而是将错误代码通过返回值返回。
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判断,因为读取返回值要比读取线程内的errno变量的开销更小。
当线程pthread_create的时候,它就会自动去执行任务。但有时候会因为线程只是创建了一个线程,然后就立刻返回了导致次线程无法完成任务。
- 主线程可以通过sleep延时来进行等待子线程完成任务
- 主线程也可以通过后面的“线程等待(pthread_join)”来进行阻塞式等待子线程完成任务
线程ID
- pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 用ps查看的线程ID属于进程调度范畴,因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程库NPTL提供了对应函数,可以获得线程自身的ID
pthread_t pthread_self(void);
pthread_t 类型的线程ID,本质上就是进程地址空间上的一个地址
线程终止
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
void pthread_exit(void *value_ptr);
- 功能:线程终止
- value_ptr是一个指向返回值的指针。这个返回值可以被其他线程通过pthread_join函数获取。返回值的类型是void*,这意味着它可以是任何类型的数据指针,例如,可以是一个指向结构体的指针,其中结构体中包含了线程的执行结果等信息。如果不需要返回任何值,可以将retval设置为NULL。
- 需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出
int pthread_cancel(pthread_t thread);
- 功能:取消一个执行中的线程
- thread:线程ID
- 返回值:成功返回0,失败错误码
线程等待
为什么需要线程等待
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
int pthread_join(pthread_t thread, void **value_ptr);
- 功能:等待线程结束
- thread:线程ID
- value_ptr:输出型参数,它指向一个指针,后者指向线程的返回值
- 返回值:成功0,失败错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。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参数。
线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源
int pthread_detach(pthread_t thread);
- 功能:把对应线程分离
- thread:分离线程的线程id
- 返回值:成功为0,失败错误码
注意:
- 一个线程分离后是无法被join的
- 线程分离后,如果出现异常,也会导致进程挂掉
3. 线程的互斥
相关概念
- 临界资源:多线程执行流共享的资源就叫做临界资源
- 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
- 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
- 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成
临界资源
- 对临界资源进行保护,本质是对临界区代码进行保护
- 对有所有资源进行访问,本质就是通过代码进行访问
- 保护资源,本质就是想办法吧访问资源的代码保护起来
- 执行流在非临界区可能是并行,而到了临界区就变成串行
互斥量 - mutex
- 多个执行流访问共享的资源必须要有互斥行为 —— 当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
那么做到这些需要一把锁,Linux提供的这把锁叫互斥量mutex
初始化互斥量
静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;(静态分配)
动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrictattr);
- mutex:输出型参数,表示要初始化的互斥量
- attr:互斥锁属性,不关注设置为NULL
- 返回值:成功返回0,失败错误码
销毁互斥量
- 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
- 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加锁
加锁的时候,会遇到这些情况:
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
int pthread_mutex_lock(pthread_mutex_t *mutex); //成功返回0,失败错误码
互斥量解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex); //成功返回0,失败错误码
死锁
概念
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。
死锁的四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
避免死锁算法
- 死锁检测算法
- 银行家算法
4. 线程的同步
条件变量
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问s题,叫做同步。
初始化条件变量
静态分配
pthread_cond_t cond = PTHREAD_COND_INITALIZER;
动态分配
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrictattr);
- 功能:初始化条件变量
- cond:输出型参数,表示要初始化的条件变量
- attr:条件变量属性,不关注可以设置NULL
销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond)
- 功能:销毁条件变量
- 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);
5. 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
6. 线程缺点
- 多个线程都是唯一的进程pid,一个线程异常,所有线程包括进程都会挂掉;而多进程就不会,每个进程具有独立性
- 性能损失
- 健壮性降低:多线程之间缺乏保护,编写需要考虑周全
- 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度高:编写与调试一个多线程程序比单线程程序困难得多
7. 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
8. 线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验
9. 一些问题
已经有多进程了,为什么要有多线程?
- 进程创建成本非常高,线程创建成本非常低(创建PCB,把已有进程的资源给它)
- 线程调度成本低,删除一个线程成本也很低
为什么线程的调度成本低?
- CPU集成了cache,进程中线程切换少了更新cache中内容这一步
- cache可以预先加载内存的内容并保存,如果命中则不需访问内存存
关于线程的私有部分
- 进程是资源分配的基本单位,线程是调度的基本单位,线程共享进程数据,也有自己一部分数据
共享部分:
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户id和组id
线程私有部分:
- 一组寄存器(执行流上下文数据)
- 栈(多个线程启动可能是无序的,如果多个线程用同一个栈,那必定无法随意出栈),因此每个线程都必须有自己独立的栈空间,此空间用于存储函数调用的局部变量、函数参数和返回地址等信息,且无法与其他线程共享。
- 线程ID
- errno
- 信号屏蔽字
- 调度优先级
关于线程和寄存器
- 每个线程都有一组寄存器,这是因为寄存器是 CPU 内部用于快速存储和操作数据的单元。在多线程环境下,当线程进行切换时,需要保存和恢复线程的执行状态,而寄存器状态是执行状态的重要组成部分。
线程切换与寄存器保存恢复:
- 当操作系统进行线程切换时,它需要将当前正在执行线程的上下文(包括寄存器的值)保存起来。这个过程就像是在阅读一本书时,你把当前看到的页码(类似于程序计数器,是一个寄存器)、书中重点标记的内容(其他寄存器存储的数据)等信息记录下来。然后,当这个线程下次被调度执行时,操作系统会把之前保存的寄存器值恢复到 CPU 的寄存器中,这样线程就可以从上次中断的地方继续执行,就好像你再次翻开书,根据记录的页码和重点继续阅读一样。
独立性和隔离性:
- 寄存器对于每个线程是私有的,这确保了每个线程在执行时可以独立地使用寄存器来存储自己的临时计算结果、函数参数、局部变量等信息,不会受到其他线程的干扰。
提高执行效率:
- 由于寄存器的访问速度比内存快得多,线程能够直接使用自己私有的寄存器来快速执行各种操作,避免了因为共享寄存器而可能产生的冲突和等待,从而提高了整个系统的执行效率。
CPU寄存器只有一套,寄存器里面的数据可以有多套
- 多个线程数据看起来放在一套公共寄存器中,但属于线程私有,当他被切换时,他要带走自己的数据回来的时候,会恢复
- CPU的寄存器只有一套,被所有的线程共享,但是寄存器里面的数据,属于执行流的上下文,属于执行流的私有数据
- CPU在指向代码的时候,一定要有对应的执行载体 – 线程&&进程
- 数据在内存中,被所有线程共享
结论:把数据从内存移动到CPU中,本质就是把数据从共享,变成线程私有