Linux--9.多线程(上)

我们之前学习过Linux之中的进程,了解了多个进程间的各种细枝末节,在今天这一章我们来学习一下线程,在这里我们先来澄清一个重要的点,Linux中并没有多线程,没有所对应的数据结构支持,在Linux中,线程实际上是通过进程进行模拟的,这个概念在我们后面的学习中会更加明确

线程的概念

在一个程序里的一个执行路线就叫做线程( thread )。更准确的定义是:线程是 一个进程内部的控制序列
一切进程至少都有一个执行线程
线程在进程内部运行,本质是在进程地址空间内运行
Linux 系统中,在 CPU 眼中,看到的 PCB 都要比传统的进程更加轻量化
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

我们这里再对两个重要的概念进行总结

进程:承担分配系统资源的基本实体

线程:调度的基本单位,线城市进程里面的执行列(在进程地址空间中运行)进程与线程之间具有1:n的关系,一个进程可以对应n个线程

有了这些概念,我们可以对线程有更清晰的认识

Linux中的线程事实上其实还是进程,只不过这种进程我们叫轻量化进程,我们的CPU在处理线程时看到的还是PCB,那么我们的既然拥有了功能完备的进程,为什么还要有线程呢?

线程的优点

创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O 密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。

我们可以发现,存在线程的根本原因,还是为了提高效率,我们开辟一个进程可能消耗大量的资源,而创建一个线程仅仅只需要构建数据结构,无需创建资源,创建进程则需要开辟新的资源,这样我们就可以将不同的任务分配给同一进程来实现资源的充分利用,也就是基于这个观点,线程才被创建了出来

线程的缺点

性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些 OS 函数会对整个进程造成影响。
编程难度提高
编写与调试一个多线程程序比单线程程序困难得多

事实上这些缺点也都是因为多个线程对应一个进程而存在的,但与之提供的优点相比,缺点也是可以接受的

线程异常

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

线程用途

合理的使用多线程,能提高 CPU 密集型程序的执行效率
合理的使用多线程,能提高 IO 密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

进程和线程之间的异同

不同:
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据 :
线程 ID
一组寄存器
errno
信号屏蔽字
调度优先级

相同:

进程的多个线程共享 同一地址空间,因此Text SegmentData Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

文件描述符表
每种信号的处理方式 (SIG_ IGN SIG_ DFL 或者自定义的信号处理函数 )
当前工作目录
用户 id 和组 id

 总结:我们的多进程之间强调独立性,多线程之间强调共享性,但这又都不是绝对的,比如进程间通信,线程间独立的数据等

关于进程线程的问题

如何看待之前学习的单进程?具有一个线程执行流的进程

POSIX线程库

与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以 “pthread_” 打头的
要使用这些函数库,要通过引入头文 <pthread.h>
链接这些线程函数库时要使用编译器命令的 “-lpthread” 选项

因为我们的Linux之中并没有真正意义上的线程,所以系统没有提供接口,但是我们有一个第三方库,只需要在头文件中包含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: 是个函数地址,线程启动后要执行的函数
arg: 传给线程启动函数的参数
返回值:成功返回 0 ;失败返回错误码

 

 我们利用函数创建了一个线程,代码执行了两个死循环,若只有一个进程则只会有一个死循环,而且这两个进程的pid相同,说明在一个进程中创建线程返回的是一个线程id

进程id与线程id

Linux 中,目前的线程实现是 Native POSIX Thread Libaray, 简称 NPTL 。在这种实现下,线程又被称为轻量级进程(Light Weighted Process), 每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct 结构体 )
没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程 ID 。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N 个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1 N 关系, POSIX 标准又要求进程内的所有线程调用
getpid 函数时返回相同的进程 ID ,如何解决上述问题呢?
Linux 内核引入了线程组的概念
多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符( task_struct )与之对应。进程描述符结构体中的pid ,表面上看对应的是进程 ID ,其实不然,它对应的是线程 ID;进程描述符中的 tgid ,含义是 Thread Group ID, 该值对应的是用户层面的进程 ID

 查看线程id:

 利用我们的ps -aL命令即可实现

在这里,我们发现了一个与之前不一样的东西,LWP,也就是线程的id

LWP

我们发现进程mythread有两个线程,一个线程的id为10890,另一个线程的id为10891,进程id为10890.

我们发现,有一个线程的id与进程是一致的,这是为什么呢?其实在我们的线程组中,这里的第一个线程在用户态被称为主线程,进程中创建的第一个线程,会将该线程的id设置为与进程一致,而其他的线程id则是由内核分配的

但是我们还需要澄清一个概念,在进程间是有父子进程这样的概念的,而在线程中就没有这样的概念了,所有线程的级别都是相同的

 线程ID及进程地址空间布局

pthread_ create 函数会产生一个线程 ID ,存放在第一个参数指向的地址中。该线程 ID 和前面说的线程 ID 不是一回事。
前面讲的线程 ID 属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create 函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID ,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程 ID 来操作线程的。
线程库 NPTL 提供了 pthread_ self 函数,可以获得线程自身的 ID

  线程终止

如果需要只终止某个线程而不终止整个进程 , 可以有三种方法 :
1. 从线程函数 return 。这种方法对主线程不适用 , main 函数 return 相当于调用 exit
2. 线程可以调用 pthread_ exit 终止自己。
3. 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程

pthread_exit函数

功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr 不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意 ,pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的 , 不能在线程函数的栈上分配, 因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel函数

功能:取消一个执行中的线程
原型
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 得到的终止状态是不同的,总结如下
1. 如果 thread 线程通过 return 返回 ,value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。
2. 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉 ,value_ ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED。
3. 如果 thread 线程是自己调用 pthread_exit 终止的 ,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
4. 如果对 thread 线程的终止状态不感兴趣 , 可以传 NULL value_ ptr 参数

 分离线程

默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值, join 是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离 :
pthread_detach(pthread_self()) ;
joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的

线程互斥

进程线程间的互斥相关背景概念

临界资源:多线程执行流共享的资源就叫做临界资源
临界区:每个线程内部,访问临界自娱的代码,就叫做临界区
互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

互斥量mutex

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
多个线程并发的操作共享变量,会带来一些问题

我们来编写一个抢票代码来进行举例

 

我们创建了4个线程去抢100张票,此时我们发现票数竟然成了负数,这说明一定是有问题的,抢票抢多了,这其实就是我们的线程安全问题

在多个线程访问临界区时,某一瞬间同时有多个进程进入临界区执行代码,同时抢了一张票但进行了多次--,为了避免这个问题,我们需要给临界资源进行加锁,保证其原子性

 

 我们在临界区中加入锁保证其原子性解决了这个问题

总结:

为什么会到负数?

if 语句判断条件为真以后,代码可以并发的切换到其他线程
usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
-- ticket 操作本身就不是一个原子操作
取出 ticket-- 部分的汇编代码
objdump -d a.out > test.objdump
152 40064b: 8b 05 e3 04 20 00 mov 0x2004e3(%rip),%eax # 600b34 <ticket>
153 400651: 83 e8 01 sub $0x1,%eax
154 400654: 89 05 da 04 20 00 mov %eax,0x2004da(%rip) # 600b34 <ticket>

其实我们的--操作也并不是安全的,是非原子的,因为需要经过一步寄存器,所以多个线程执行操作是有可能产生线程安全问题的

load :将共享变量 ticket 从内存加载到寄存器中
update : 更新寄存器里面的值,执行 -1 操作
store :将新值,从寄存器写回共享变量 ticket 的内存地址

解决方法:

代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁。 Linux 上提供的这把锁叫互斥量

 互斥量的接口

我们在使用了互斥量之后,还应该对其接口进行研究,让我们来看看这些互斥锁的接口

初始化

方法1:静态分配

//使用宏来静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

这种方法其实是利用宏来对锁进行初始化

方法2:动态分配

 int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

作用:初始化互斥量

参数:restrict mutex:要初始化的互斥量

restrict attr:属性,设为NULL,使其相同分配

返回值:成功返回0,失败返回错误码

销毁互斥量

销毁互斥量需要注意:
使用 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 调用会陷入阻塞 ( 执行流被挂起 ) ,等待互斥量解锁

总结

我们的互斥量一般应用在临界资源中解决线程不安全问题的,一般来说,当一个操作不是原子的,就可能会出现线程安全问题,所以我们需要对其进行加锁,因为锁本身就是原子性的,所以当一个线程访问临界资源时,会对资源进行加锁,此时其他线程就会处于阻塞状态,等这个线程对资源访问后,解锁,才能对其进行访问,注意,这里无法访问也包括了时间片轮转切换线程的情况,也是不能访问的,所以加锁后我们的效率是一定会有所降低的

互斥锁实现原理

因为我们的锁在同一时间内需要保证仅有一个线程进入临界区,那么锁本身是一定要保证自身原子性的,,那么锁是如何保证自己的原子性的呢?

其实锁在底层,大多数体系都提供的swap与exchange指令,该指令是将寄存器与内存单元的数据进行交换,是只有一条指令的,不会出现中间态,所以才能保证其原子性

 我们来剖析一下加解锁的过程

加锁:多条线程去执行加锁函数,当执行到movb $0 %al命令时,假设我们寄存器中得值为0,由于mutex调用了pthread_init函数,初始化为1(假设1未锁,0为加锁)

若一个线程1抢占执行到xchgb %al mutex(将mutex中内存单元与寄存器al中的值进行交换)此时al值等于1,mutex空间中的值为0

若此时有另一个线程2执行到exchb %al mutex命令,线程1中有一组寄存器TSS,会保存运行时CPU产生的上下文数据,于是会将al=1的值保存到寄存器中,不会讲mutex的值保存

线程2会将自己的数据存放到寄存器中,al的值为0,此时mutex的值也为0,即使执行命令到xchgb %al mutex交换后,执行判断语句后会挂起等待其他线程等待,也是一样的道理

当线程1回来时,会将自己保存的数据恢复,al值等于1,此时执行判断语句,就加上了锁

解锁:可以执行到解锁的线程一定是成功加锁的代码,没有加锁的只能等待

我们只需要将mutex内存的值写为1就好了,仅有一句汇编,是原子的,无需将al置为0,甲所示第一条就是讲al置为0

可重入VS线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况

不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数

常见的线程安全的情况

每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性

常见不可重入的情况

调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的
调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构

常见可重入的情况

不使用全局变量或静态变量
不使用用 malloc 或者 new 开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

可重入与线程安全联系

函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

死锁

概念

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态

死锁四个必要条件

互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件 : 一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件 : 若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

破坏死锁的四个必要条件
加锁顺序一致
避免锁未释放的场景
资源一次性分配

线程同步

同步的必要性:我们线程在互斥的情况下,一个线程访问某个变量,未改变状态之前其他线程只能进行等待,这很浪费,所以我们需要在这个空隙让休息的线程去解决其他们问题

条件变量

当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量

同步概念与竞态条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

条件变量函数

初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:
cond :要初始化的条件变量
attr NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond :要在这个条件变量上等待
mutex :互斥量,后面详细解释
为什么 pthread_ cond_ wait 需要互斥量 ?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

 

唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

条件变量使用规范

等待条件代码
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);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值