操作系统总结(三)——线程

2.3 线程

线程的实现、线程调度、线程同步典型的锁、线程锁、回收线程、死锁

1.线程的概念和上下文切换

线程程序执行的基本单位,是轻量级的进程,一个进程可以包含多个线程,线程不能脱离进程单独存在,只能依赖于进程运行,线程之间可以并发运行并且共享相同的地址空间。

当两个线程不属于同一个进程时,切换方式和进程一致;而当两个线程属于同一个进程时,线程共享的资源保持不变,只需要切换线程的私有数据,所以相比于进程切换,线程切换的开销要小很多。

线程的内存结构如图所示:

2.线程的互斥与同步

线程间的互斥:如果多个线程在同一时刻需要访问同一资源,这个被访问的资源被称为临界资源,多个线程同时访问临界资源时,会发生竞争操作,如果执行过程尚未完成就发生了线程上下文切换,可能会导致错误的结果。所以临界资源不能给多个线程同时执行,即当一个线程访问临界资源时,其他线程应该被阻止访问,这种情况就是线程间的互斥

线程间的同步:控制多个线程的执行顺序,这就是线程间的同步

线程间的互斥主要通过加锁和信号量来实现,线程间的同步主要以下:临界区和互斥对象、信号量和事件对象。其中临界区和互斥对象主要用户互斥控制,信号量和事件对象主要用于同步控制。

(1)临界区和互斥对象

对临界资源进行访问的那段代码称为临界区,只有拥有互斥对象的线程才有访问临界资源的权限,如果有多个线程想访问临界区,在任一时刻只允许一个线程进入,当已经有一个线程进入临界区后,其他线程将被阻塞。

(2)信号量和事件对象

信号量(Semaphore)的主要用途为:调度线程。它主要负责协调各个线程, 以保证它们能够正确、合理的使用公共资源。信号量也可以用来防止共享资源冲突,但这不是它的主要功能。信号量允许多个线程同时访问临界资源,信号量的值是一个非负整数,所有通过它的线程都会将该整数减一。如果计数器大于0,则访问被允许,计数器减1;如果为0,则访问被禁止,所有试图通过它的线程都将处于阻塞状态。事件对象主要通过通知操作的方式来实现线程的同步

(3)互斥锁和信号量的区别

1)互斥量用于线程的互斥,信号量用于线程的同步。这是互斥量和信号量的根本区别,也就是互斥和同步之间的区别。

2)互斥量值只能为0/1,信号量值可以为非负整数。 也就是说,一个互斥量只能用于一个资源的互斥访问,它不能实现多个资源的多线程互斥问题。 信号量可以实现多个同类资源的多线程互斥和同步。当信号量为单值信号量是,也可以完成一个资源的互斥访问。信号量是通过一个计数器控制对共享资源的访问,信号量的值是一个非负整数,所有通过它的线程都会将该整数减一。如果计数器大于0,则访问被允许,计数器减1;如果为0,则访问被禁止,所有试图通过它的线程都将处于等待状态。

3)互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

4) 从功能的角度看,锁是服务于共享资源的;而信号量是服务于多个线程间的执行的逻辑顺序的。(一般来说,要对共享资源进行保护,则使用互斥锁而要协调多个线程,则使用信号量。使用信号量对共享资源的保护的确可行但是是对信号量的一种错用)

3.死锁

一般而言,每一个临界资源都需要一个线程锁进行保护。但如果出现了下图所示的情况:

上图中,mutex1锁和mutex2锁为了分别为了保护两个不同的临界资源而设置,则可能会出现如下情况:线程1获取了mutex1锁,线程2获取了mutex2锁,由于锁如果被其他线程获取时,当前线程会进入等待状态,此时线程1想要获取mutex2线程锁时,要等待线程2释放,线程2想要获取mutex1线程锁时,要等待线程1释放,相互等待最终出现死锁现象。死锁现象是一个环路,如下图所示:

理论上认为死锁产生有以下四个必要条件,缺一不可:

1)互斥条件:进程对所需求的资源具有排他性,若有其他进程请求该资源,请求进程只能等待。

2)不剥夺条件:进程在所获得的资源未释放前,不能被其他进程强行夺走,只能自己释放。

3)请求和保持条件:进程当前所拥有的资源在进程请求其他新资源时,由该进程继续占有。

4)循环等待条件:存在一种进程资源循环等待链,链中每个进程已获得的资源同时被链中下一个进程所请求。

死锁的避免:

避免死锁只需要破坏掉其中的一个必要条件就可以了,最常见可行的办法是资源有序分配法。该方法为:线程1和线程2获取临界资源的顺序要一样,当线程1是先获取资源1,后获取资源2时,线程2也应该按照这样的顺序,同时保证上锁和解锁的顺序一致。

4.常见的锁

加锁的目的就是保证临界资源在任意时间里,只有一个线程访问,常见的锁如下所示:

(1)互斥锁

互斥锁是一种独占锁,当一个线程A想要通过pthread_mutex_lock操作去得到一个临界区的锁时,发现该锁已经被另一个线程B所持有,此时线程A进入睡眠状态,等到锁被释放后,内核会在合适的实际唤醒线程A。线程A进入睡眠状态后,操作系统会通过上下文切换将线程A置于等待队列中,同时线程释放CPU给其他线程。

条件变量:互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制

(2)自旋锁

自旋锁与互斥锁类似,不同的是,当线程A发现锁被线程B所持有时,线程A会忙等待,不停的进行锁请求,直到它拿到锁,这期间不会进入睡眠状态。

优点:自旋锁不会引起调用者睡眠,减少了上下文切换的开销,所以自旋锁的效率远高于互斥锁。

缺点:

  • 自旋锁一直占用CPU,在未获得锁的情况下,一直进行自旋,所以占用着CPU,如果不能在很短的时间内获得锁,无疑会使CPU效率降低。

  • 在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁。

(3)读写锁

读写锁是由【读锁】和【写锁】组成,即读取临界资源使用【读锁】加锁,修改临界资源使用【写锁】加锁。基于以上特点,读写锁适用于能明确区分读操作和写操作的场景。

读写锁的特点:

  • 没有使用【写锁】时,多个线程可以同时持有【读锁】。

  • 【写锁】被持有时,【读锁】将被阻塞,且【写锁】是互斥的。

  • 读写锁还包括【读优先锁】和【写优先锁】,【读优先锁】为:如果先有了线程A先有了读锁,线程B再获取写锁时,会被阻塞,获取读锁不会被阻塞。【写优先锁】为:如果先有了线程A先有了读锁,线程B再获取写锁时,会被阻塞,但再获取读锁也会被阻塞,线程A释放读锁后,优先获取写锁。

(4)乐观锁和悲观锁

悲观锁:先获取锁,再访问临界资源。互斥锁、自旋锁和读写锁都是悲观锁。

乐观锁:先访问和修改临界资源,再验证有无冲突,如果没有则操作完成,如果有则放弃操作。乐观锁也叫无锁编程,一般多线程冲突概率很低,且加锁成本很高时,才会使用乐观锁,如多人在线编辑文档。

5.线程安全?如何实现

(1)线程安全

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

线程安全问题都是由全局变量及静态变量引起的。但是,如果每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的。如果存在写操作,就需要考虑线程互斥或者同步问题。

(2)如何实现线程安全

实现线程安全无外乎围绕线程私有资源和线程临界资源这两点,需要识别出哪些是线程私有,哪些是共享的,这是核心,然后对症下药就可以了。

  • 同步互斥,前面讲到的互斥同步就能有效的实现线程安全。

  • 不使用任何全局资源,只使用线程私有资源,这种通常被称为无状态代码

  • 线程局部存储,如果要使用全局资源,是否可以声明为线程局部存储,因为这种变量虽然是全局的,但每个线程都有一个属于自己的副本,对其修改不会影响到其它线程

  • 只读,如果必须使用全局资源,那么全局资源是否可以是只读的,多线程使用只读的全局资源不会有线程安全问题。

  • 原子操作,原子操作是说其在执行过程中是不可能被其它线程打断的,像C++中的std::atomic修饰过的变量,对这类变量的操作无需传统的加锁保护,因为C++会确保在变量的修改过程中不会被打断。我们常说的各种无锁数据结构通常是在这类原子操作的基础上构建的

 

3.多进程和多线程编程

具有代表性的并发服务器端实现模型和方法:

1)多进程服务器:通过创建多个进程提供服务

2)多路复用服务器:通过捆绑并统一管理I/O对象提供服务

3)多线程服务器:通过生成与客户端等量的线程提供服务

前面已经讲过多进程编程时相关的知识,如进程控制、进程间通信、进程调度,多进程编程中,上下文切换需要较大的开销,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息,这就是上下文切换。但是此时进程 A 的数据将被移动到硬盘,所以上下文切换要很长时间,即使通过优化加快速度,也会存在一定的局限。相比之下,多线程编程中线程的创建和上下文切换比进程更快,下面重点讲一下多线程编程。

3.1 线程常用函数

(1)线程的创建和等待

线程具有单独的执行流,因此需要单独定义线程的main函数,在Linux下,部分函数声明如下所示:

#include <pthread.h>
​
//线程创建
int pthread_create(pthread_t *restrict thread,
                   const pthread_attr_t *restrict attr,
                   void *(*start_routine)(void *),
                   void *restrict arg);
​
/*
成功时返回 0 ,失败时返回 -1
thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID
attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程
start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针)
arg : 通过第三个参数传递的调用函数时包含传递参数信息的变量地址值
*/
​
​
//线程等待,调用该函数的进程(或线程)将进入等待状态,直到第一个参数为ID的线程终止为止
int pthread_join(pthread_t thread, void **status);
/*
成功时返回 0 ,失败时返回 -1
thread : 该参数值 ID 的线程终止后才会从该函数返回
status : 保存线程的 main 函数返回值的指针的变量地址值
*/
​
​
//线程销毁
#include <pthread.h>
int pthread_detach(pthread_t th);
/*
成功时返回 0 ,失败时返回其他值
thread : 终止的同时需要销毁的线程 ID
*/

(2)线程安全函数

根据临界区是否引起问题,函数可以分为以下2类:

1)线程安全函数(Thread-safe function)

2)非线程安全函数(Thread-unsafe function)

线程安全函数被多个线程同时调用也不会发生问题,反之,非线程安全函数被同时调用时会引发问题。但这并非有关于临界区的讨论,线程安全的函数中同样可能存在临界区。只是在线程安全的函数中,同时被多个线程调用时可通过一些措施避免问题。

操作系统在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数, 线程安全的函数结尾通常是_r。但是使用线程安全的函数会给程序员带来额外的负担,可以通过声明头文件前定义 _REENTRANT 宏将线程不安全函数调用改为线程安全函数,也可以指定编译参数定义宏,示例如下:

//线程不安全函数
struct hostent *gethostbyname(const char *hostname);
​
//线程安全函数
struct hostent *gethostbyname_r(const char *name,
                                struct hostent *result,
                                char *buffer,
                                int intbuflen,
                                int *h_errnop);
​
//无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。
gcc -D_REENTRANT mythread.c -o mthread -lpthread

(3)互斥量和信号量函数

#include <pthread.h>
#include <semaphore.h>
​
//互斥量的创建及销毁函数
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
/*
成功时返回 0,失败时返回其他值
mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址
attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL
*/
​
​
//互斥量锁住或释放临界区时使用的函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
/*
成功时返回 0 ,失败时返回其他值
*/
​
​
//信号量的创建及销毁
int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
/*
成功时返回 0 ,失败时返回其他值
sem : 创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值
pshared : 传递其他值时,创建可由多个继承共享的信号量;传递 0 时,创建只允许 1 个进程内部
使用的信号量。需要完成同一进程的线程同步,故为0
value : 指定创建信号量的初始值
*/
​
​
//信号量中相当于互斥锁lock、unlock的函数
int sem_post(sem_t *sem);
int sem_wait(sem_t *sem);
/*
成功时返回 0 ,失败时返回其他值
sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时
信号量减一
*/

 

3.2 多进程和多线程的对比

(1)私有和共享的资源

进程之间私有和共享的资源:

  • 私有:地址空间、堆、全局变量、栈、寄存器

  • 共享:代码段,公共数据,进程目录,进程 ID

线程之间私有和共享的资源:

  • 私有:线程栈,寄存器,程序计数器

  • 共享:堆,地址空间,全局变量,静态变量

(2)多进程与多线程间的对比、优劣与选择

对比

对比维度 多进程 多线程 总结
数据共享、同步 数据共享复杂,需要用 IPC;数据是分开的,同步简单 因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU 利用率低 占用内存少,切换简单,CPU 利用率高 线程占优
创建销毁、切换 创建销毁、切换复杂,速度慢 创建销毁、切换简单,速度很快 线程占优
编程、调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会互相影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布式;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布式 进程占优

优劣

优劣 多进程 多线程
优点 编程、调试简单,可靠性较高 创建、销毁、切换速度快,内存、资源占用小
缺点 创建、销毁、切换速度慢,内存、资源占用大 编程、调试复杂,可靠性较差

选择

  • 需要频繁创建销毁的优先用线程

  • 需要进行大量计算的优先使用线程

  • 强相关的处理用线程,弱相关的处理用进程

  • 可能要扩展到多机分布的用进程,多核分布的用线程

  • 都满足需求的情况下,用你最熟悉、最拿手的方式

 

参考:

  1. 《深入理解计算机系统》

  2. 《图解系统》- 小林coding
  3. 《TCP-IP网络编程》 韩-尹圣雨

  4. 《Linux高性能服务器编程》

  5. https://github.com/huihut/interview

  6. https://blog.csdn.net/qq_45593575/article/details/120779693

 

posted @ 2021-11-13 16:40  烟消00云散  阅读(109)  评论(0编辑  收藏  举报