Linux多线程_(进程与线程,线程的生命周期认识线程,线程互斥)
Posted 楠c
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux多线程_(进程与线程,线程的生命周期认识线程,线程互斥)相关的知识,希望对你有一定的参考价值。
目录
1. 进程与线程
进程:承担分配资源实体的基本单位。
线程:调度的基本单位。线程是进程内部一条执行流。线程在进程的地址空间运行。
所有的一整块叫进程,每一个task_struct是一条执行流。
- 也就是说,cpu在调度的时候是以线程为基本单位,看到的是地址空间的一部分。
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
之前没接触线程之前,大多接触和看到的都是一个进程里面只有一个执行流。而在上图就是一个进程多个执行流的例子。
在Linux中,没有专门为线程设计数据结构,线程是用task_struct模拟出来的。而轻量级进程就和他关联起来。就可以实现内核调度用户创建的线程了。
注意区分多进程和多线程,多线程一份地址空间,多进程多个地址空间。
2. 线程的优缺点
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多 - 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现(cpu资源)
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。(带宽,内存资源)
其实后4点,进程也能做,不过优点大家可以是共同的。
线程的缺点:
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。
如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。(必须要加锁,加锁之后带来的) - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。(多进程访问一个变量时,如果修改会自动出现两份变量,但多线程只会看见一个变量,不加锁修改就出问题了) - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多,主要是调试。
3. 线程异常
程序执行时,一个进程一个执行流,经过用户创建线程,变成一个进程多个执行流,而其中一个执行流出现异常,操作系统发信号,进程接收到信号,释放资源,全部线程都不复存在
4. 进程和线程总结
-
进程是资源分配的基本单位
-
线程是调度的基本单位
-
线程共享进程数据,但也拥有自己的一部分数据:
线程ID(用户级就是那一串地址,内核级就是lwp)
一组寄存器(保存上下文,可以进程切换)
栈(就是用户库里实现的,变量不冲突)
errno(全局变量,临界资源,所以需要各自私有)
信号屏蔽字(block集,pending没有私有)
调度优先级 -
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,各线程还共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
多进程强调独立但不是完全独立,比如fork之后,进程间通信。
多线程强调共享但不是完全共享,他也要有自己私有的数据。
5. P-thread库
P-thread库采用prosix标准,是一个用户级别库
5.1 线程创建,pthread_create
Linux没有关于线程的数据结构,所以没有创建线程的接口,但是他有创建轻量级进程的接口,线程会与轻量级进程关联起来。P-thread库就是第三方提供的一套库,所以链接的时候需要链接这个库。
他为什么不加-i,-L,那些呢,因为他是在系统默认目录下的。
而在用户的库,也要对这个线程先描述在组织。描述用结构体描述,组织用数组
- 第一个参数是输出型参数,无符号长整形,用户级线程的id,操作系统是看不见的,属于用户级id,他和线程的地址数值相等
- 第二个参数是线程的属性,
- 第三个参数是返回值为void*,参数为void*的函数指针。
- 第四个参数是传递给函数指针的参数
- 返回值为错误码(传统函数成功返回0,失败返回-1)
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void *args)
{
while(1)
{
printf("i am %s\\n",(char*)args);
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,NULL,thread_run,(void*)"thread 1");
while(1)
{
printf("i am main pthread\\n");
sleep(1);
}
return 0;
}
两个循环同时打印,因为有两个执行流,其中main中的叫做主执行流。
当./运行这个程序的时候,内存中就有一个进程,两个执行流。
加入getpid接口,返现他们的pid一致。
ps -aL,查看
lwp为轻量级进程,而一个轻量级进程调用一个线程,cpu调度是以他为基本单位,线程同属于一个线程组(大进程),getpid返回为线程组的pid。为所以操作系统就能区分你们是同一个进程的两个执行流
5.2 获取用户级线程id,pthread_self
获取自己用户级的线程id
打印变量tid
5.3 线程终止,pthread_exit函数
5.3.1 进程终止的三种情况
需要注意的是
main函数中return相当于exit(),相当于终止进程,进程的地址空间都没了,肯定都完蛋了。
但是主线程调用pthread_exit的话是不会影响别的线程的。
这两种都是线程自己主动退出。
这种是通过pthread_cancel,可以别的线程通过你的用户级线程id来终止线程。
5.4 线程等待,pthread_join
在主线程中等待create创建的线程,在thread 1 sleep的10s中,主线程一直在阻塞式等待。
5.4.1 为什么需要线程等待?
和进程类似,线程退出,没有其他进程等待,也会造成类似僵尸进程一样的结果,导致内存泄漏。所以需要等待。
其他行为和进程类似吗?在之前学到,一个进程退出方式有三种,代码运行完,结果正确。代码运行完结果错误,进程异常终止。前两个可以获取退出码,后一个可以可以查看退出信号。
那线程等待,也是一样吗。肯定不一样,任意一个线程异常,整个进程直接结束,因为操作系统是向进程发信号的。
所以线程等待,只关心他的退出码,也就是我们只关系线程退出时正确与否的退出码,异常是不关心的,你一错进程去背锅。因为信号是给进程设计的。也侧面说明了,虽然block表线程各自私有,pending表属于整个进程。
5.4.2 线程等待的四种情况
线程终止有三种情况,那么对应的join等待也会得到3种不同的状态。还有我们不关系他的状态可以设置为NULL。
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。他为(void*)-1。
- 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
5.5 线程分离
可以自己把自己分离。
也可以别人将你分离
这种经过分离的线程就不需要在pthread_wait,在他结束后自动回收资源。
虽然分离!!但是线程异常也会影响其他线程。
6. 线程互斥
6.1 互斥引出
在线程中,子进程不修改这个数据时,数据只有一份与父进程共享,当修改时发生写时拷贝将数据私有。这个两个数据的虚拟地址一样,但是虚拟地址空间不是一套,经过页表转换后的物理地址不同。
但在线程中,由于线程共享地址空间。此时代码中a为全局变量,全局变量处于数据段不私有。所以他们即使修改了,也访问的是同一个数据。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int a=10;
void* pthread_run(void* str)
{
while(1)
{
sleep(1);
printf("%s, %d\\n",(char*)str,a);
}
}
int main()
{
pthread_t t1,t2,t3,t4;
pthread_create(&t1,NULL,pthread_run,(void*)"mythread1");
pthread_create(&t2,NULL,pthread_run,(void*)"mythread2");
pthread_create(&t3,NULL,pthread_run,(void*)"mythread3");
pthread_create(&t4,NULL,pthread_run,(void*)"mythread4");
sleep(5);
a=20;
sleep(5);
//前5s打印10,后5s打印20
//
//
//写上等待规范一点,实际上那个是死循环,不退出。
pthread_join(t1,NULL);
pthread_join(t2,NULL);
pthread_join(t3,NULL);
pthread_join(t4,NULL);
return 0;
}
一个地址空间,数据段没有私有,看到同一个数据,虚拟内存,物理内存一致。
a就是一个临界资源。printf是临界区
- 临界资源:多个执行流可能同时访问的要有修改权限的资源叫临界资源,(只读权限的话不叫临界资源)
- 临界区:线程内部访问临界资源的代码,叫做临界区
在实际生活中是怎么抢票的呢?
当票大于0,用户购买,票数–,当票数小于0,那么就wait等待重新放票。ticket叫做临界资源,if与距离的就叫做临界区。
ticket–,++,都不是原子性,因为它要经历三个过程,对应三条汇编指令。
load :将共享变量ticket从内存加载到寄存器中
update : 更新寄存器里面的值,执行-1操作
store :将新值,从寄存器写回共享变量ticket的内存地址
在这三个过程当中,ticket肯定有未发生变化的过程,所以当另一个线程来访问它的时候,就可能访问的是未修改的值。就有可能多个用户抢到一张票。甚至只剩一张票时,多个执行流对ticket>0,进行判断时多个用户拿到同一个最后一张票,因为ticket–并不是原子性的。
所谓原子性就是只有两态,要么有,要么没有,其实也可以有中间状态,但是只要你不影响其他人,也可以认为你具有原子性。
我们可以模拟一个抢票的多线程程序,来看看
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int ticket=100;
void* route(void* args)
{
while(1)
{
usleep(1000);
if(ticket>0)
{
ticket--;
printf("thread %d get %d \\n",(int)args,ticket);
}
else{
break;
}
}
}
int main()
{
pthread_t tid[4];
int i=0;
for( i=0;i<4;i++)
{
pthread_create(&tid[i],NULL,route,(void*)i);
}
int j=0;
for(j=0;j<4;j++)
{
pthread_join(tid[j],NULL);
}
return 0;
}
由于非原子性,造成了抢票出现bug。
由于这三种情况导致的
- if 语句判断条件为真以后,代码可以并发的切换到其他线程(多个cpu)
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- –ticket 操作本身就不是一个原子操作
本质上下面三种方法可以解决。
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
6.2 锁(互斥量)
要做到这三点,Linux提供了一把mutex锁,叫做互斥量。锁也有很多,pthread有对应的数据结构描述,组织。
6.2.1 创建锁
要给线程加锁,首先要让他们看到同一把锁
pthread_mutex_t lock;
6.2.2 初始化锁
pthread_mutex_init(&lock,NULL);
6.2.3 加锁与 解锁
pthread_mutex_lock(&lock);
pthread_mutex_unlock(&lock);
6.2.4 销毁锁
pthread_mutex_t destory(&lock);
那么将刚才有问题的抢票代码,修改加上锁。
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
int ticket=100;
pthread_mutex_t lock;
void* route(void* args)
{
while(1)
{
usleep(1000);
pthread_mutex_lock(&lock);
if(ticket>0)
{
ticket--;
printf("thread %d get %d \\n",(int)args,ticket);
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
break;
}
//假如在这里解锁,假如在之前break了,进程带着锁跑了,这个解锁就不执行了。
//pthread_mutex_unlock(&lock);
}
}
int main()
{
pthread_t tid[4];
pthread_mutex_init(&lock,NULL);
int i=0;
for( i=0;i<4;i++)
{
pthread_create(&tid[i],NULL,route,(void*)i);
}
int j=0;
for(j=0;j<4;j++)
{
pthread_join(tid[j],NULL);
}
pthread_mutex_destroy(&lock);
return 0;
}
一切正常
- 当这一把锁保护了临界区,意味着进来的所有线程都必须遵守规则。
- 即先lock->访问临界区->unlock
- 那么所有的进程都看到了这把锁,锁也是一种临界资源,–不是原子性,所以要在外面加锁,那么锁就要保证不会出现中间态。那么他是怎么实现的呢?
- 在一个线程,lock->访问临界区->unlock,访问临界区执行任务的时候,另一个线程来了,另一个线程申请锁,没有申请到,那么他就会阻塞等待,将线程pcb放到等待队列中。假如锁unlock,就把线程唤醒,从等待队列拿出来,状态设为R,之后调度器开始调度
- mutex,实际是一个结构体 ,
struct mutex
{
int lock;//0或1
wait_queue *head;
}
加锁代表由1变0,解锁代表由0变1。
6.3 互斥锁实现原理
lock:
当每个线程执行第一步时,由于线程的寄存器私有,互不影响
第二步
交换寄存器和mutex的值,那么此时来了其他线程会不会影响呢?
也是不会的,第二个线程来,执行第一条语句寄存器置为0,mutex并不私有,交换mutex的值,mutex的值此时为0,那么0和0交换,此时他为0,执行else于是挂起等待。在这个过程中和传统的内存到寄存器不同,这里的是exchange交换,并不是传统的拷贝,实际mutex为1的时候,只有一个进程会有。
然后申请到锁的那个线程,切换回来执行if申请成功。
unlock:
能解锁的一定是加过锁的,能走到这个unlock的语句的代码,一定只有一条。
这里寄存器的值为什么还是1呢,因为一把锁只能申请一次。
6.4 可重入与线程安全
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
假如多个线程调用一个函数,导致出错。那么这个函数叫做不可重入函数,发生的情况叫线程安全。
他们的情况也可以多了解。
6.5 死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
两个线程,线程1,拥有1锁申请2锁,线程2拥有2锁,申请1锁,且双方互不释放锁。
死锁四个必要条件
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
产生死锁,一定是由于着四个条件同时发生。破坏死锁只需要破坏其中一个条件就好。
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
算法:
- 死锁检测算法
- 银行家算法
银行家算法是一种避免出现死锁的算法,将系统运行分为两种状态,安全、非安全,有可能出现风险的都属于非安全,但是非安全只代表有可能出现风险并不代表一定发生,他的核心思想就是避免出现"环路等待"的条件。
一个线程一把锁,也可以产生死锁,当你还未释放时,再次申请锁,由于锁被自己拿着,没有释放,是申请不到的,一直被挂起。
以上是关于Linux多线程_(进程与线程,线程的生命周期认识线程,线程互斥)的主要内容,如果未能解决你的问题,请参考以下文章
day11(多线程,唤醒机制,生产消费者模式,多线程的生命周期)