linux--线程
Posted 水澹澹兮生烟.
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了linux--线程相关的知识,希望对你有一定的参考价值。
目录
一.线程概念
线程又称之为轻量级进程,代表一个进程中某个单一顺序的控制流。线程是进程号中的一个实体,是被系统独立调度和分配的基本单位。
图1
1.1pid&tgid
- pid:线程号(轻量级进程号),内核当中没有线程的概念,称之为轻量级进程。线程的概念是c标准库当中的概念。
- tgid:线程id,对标的就是进程id。
注意:在主线程中pid就相当于pgid。而在工作线程中同一个线程组当中的tgid是相等的,标识的是同一个进程,pid是不同的,标识不同的线程。
1.2线程标识符
主线程和工作线程指向的是同一个虚拟地址空间,我们在这里假设主线程和工作线程在执行时会分别调用所需函数进行压栈到同一个空间,那么此时就会出现调用栈混乱的问题。而线程真正的工作是入上图1所示,当我们创建了一个线程,就会在共享区创建一个独属于这个线程的空间,而线程标识符则就是这个空间的首地址。
1.3线程的独有和共享
- 独有:线程里面独有的有线程号,独有的栈,errno(系统错误码),独有的信号屏蔽字(block位图),一组寄存器(在线程切换的时候使用,在线程切换的时候要保存程序设计器和上下文信息,而上下文保存的信息就在这个里面),调度优先级。
- 共享:线程共享的有文件描述符,信号的处理方式,当前的工作目录(当前进程在哪一个目录启动),用户id和用户组id。
1.4线程的优缺点
在探究它的优缺点的时候,我们的先知道并行与并发。
- 并行:多个执行流在同一时刻拿着不同的cpu继续进行运算,执行代码
- 并发:多个执行流在同一时刻有且只有一个执行流拥有cpu进行运算,执行代码
1.4.1线程的优点
- 创建一个新线程的代价就要比创建一个新的进程小得多。
- 与进程之间切换相比,线程之间的切换需要操作系统做的工作很少
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作的同时,程序可执行其他的计算任务
- 就算密集型应用,为了能够在多处理器系统上机型运算,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.4.2线程的缺点
- 性能损失:如果一个进程当中多线程在的继续切换,则程序的运行效率有可能降低。因为,性能损失在了线程切换当中。一个进程中进程执行的执行效率一定会随着线程数量增加,性能呈现正态分分布状态。
- 健壮性(鲁棒性)降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 变成难度提高:编写与调试一个多线程比单线程程序更困难
1.5多进程和多线程的区别
- 多进程是指一个程序运行起来拥有多个进程,如守护进程,bash。而多线程指的是一个进程当中有多个线程。
- 从内核角度我们可以知道,多进程程序需要重新建task_struct结构体,在这个结构体当中,他是使用了自己的进程虚拟地址空间,而多线程虽然创建了task_struct结构体,但是他里面的内存指针是指向进程的虚拟地址空间的。
- 多进程程序某个进程崩溃不会影响其他进程,但是会造成资源浪费,多线程程序中的某个线程崩溃会引发整个进程崩溃。
- 线程是操作系统调度的基本单位。线程在内核当中也是一个task_struct结构体,他会被挂到内核当中的双向链表中,为了方便操作系统进行调度。那么从内核角度理解,线程就是操作系统调度的基本单位。
- 进程是操作系统分配资源的基本单位。一个进程当中可以拥有多个线程,也就是书可以拥有多个执行流,而操作系统分配资源时并非给线程分配资源,而是给进程分配资源。因此进程是操作系统分配资源的进本单位。
二.线程控制
2.1线程创建
int pthread_create(pthread_t *thread,const pthread_attar_t *attr,
void *(*start_routine) (void *),void *arg);
参数:
- pthread_t *thread:pthread_t线程标识符类型,出参
- attr:线程属性,一般情况下传递NULL,采用默认属性
- void *(*start_routine) (void *):新城的入口函数,线程执行代码的开始就是从当前函数指针保存的函数开始运行,线程入口函数的返回值为void*,参数为void*
- void *arg:给线程入口函数传递参数使用
返回值:成功时返回0,失败时返回错误码。
线程入口函数的参数传递结论:
- 结论一:线程入口函数的参数不要传递临时变量,会造成代码崩溃
- 结论二:传递的堆上开辟空间,线程不在使用的情况下,线程进行释放
- 结论三:线程入口函数的参数不仅仅可以传递内置类型,也可以传递自定义类型(类的实例化对象,this指针,结构体指针)
2.2线程终止
线程终止的三种情况:
- 线程的入口函数的return返回,当前线程就退出了
- 线程调用pthread_exit函数,谁调用谁退出
int pthead_exit(void *retval);
- 线程调用pthread_cancel函数,这个函数的作用是取消(退出)传递进来thread进程
int ptread_cancel(pthread_t thread); //获取自己的线程标识符,谁调用返回谁的线程标识符 pthread_t pthread_selt(void);
2.3线程等待
默认创建线程的时候,线程的属性时joinable属性。joinable会导致线程在退出的时候需要别人来回收自己的资源(换一句话说,线程退出了,但是线程在共享区当中的空间还没有释放),因此我们就提出了线程等待。pthread_join()函数是阻塞等待的方式。
int pthread_join(pthread_t thread, void **retval);
参数:
- thread:线程标识符,等待哪一个线程退出
- void**:接收线程退出的退出信息,退出信息从以下三个方面而来:
入口函数return返回 void* pthread_exit返回 接受的是pthread_exit的参数 pthread_cancel PTHREAD_CANCELED(它是个常数)
2.4线程设置分离属性
一个线程如果被设置成分离属性,则该线程不需要其他执行流回收该线程的资源,而是由操作系统进行回收。
//给所传进来的thread线程设置分离属性
//thread县城也可以是自己
int pthread_detach(pthread_t thread);
代码验证:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* MyThreadStrat(void* arg){
(void)arg;
//也可以直接放在之前,直接自己讲自己分离掉
//pthread_detach(pthread_self());
while(1){
printf("i am MyThreadStrat\\n");
//pthread_exit(NULL);
pthread_cancel(pthread_self());
sleep(1);
}
sleep(20);
return NULL;
}
int main(){
pthread_t tid;
int ret = pthread_create(&tid, NULL, MyThreadStrat, NULL);
if(ret != 0){
perror("pthread_create");
return 0;
}
//在主线程创建完毕一个工作线程后,将工作线程设置为分离属性,也就意味着在pthread_cancel之后不会在内存泄漏问题,刚刚使用到的线程独有空间就会被操作系统回收。
pthread_detach(tid);
//pthread_join(tid, NULL);
while(1){
printf("i am main thread\\n");
sleep(1);
}
return 0;
}
三.线程安全
3.1从线程切换角度理解线程不安全现象
- 如上图所示,假设有两个进程A,B,现在内存当中的保存了一个全局变量g_t=10,两个进程都将要对g_t进行减法操作(g_t--)。
- 步骤(1)此时线程A拿到cpu资源,在cpu内进行计算,当前线程A将g_t的值读到寄存器当中时线程A刚好要切换出去,程序计数器当中保存了下一次执行线程A的时候的下一条汇编指令,上下文信息则保存了寄存器当中的值。
- 步骤(2)和步骤(3)线程B拿到cpu资源后完成一整个计算并且将结果回写给了内存,那么此时g_t的值进行了改变g_t=9,
- 步骤(4)然后线程A再一次切换回来,执行下面的计算,然后再将结果回写给内存。
- 根据上面的步骤可知,如果是抢占式执行最终的结果有可能为g_t=9,但是有时候有可能g_t=8,此时返回的结果产生了二义性,因此,这种情况下线程是不安全的。
3.2保证线程安全--互斥锁
假设一个系统有两个终端,每个终端上分别运行进程a和进程b。两个进程并发执行,并且通过共享的数据库发生交互,如果对数据库的访问顺序不加以控制,那么就可能得到错误的执行结果。为了防止这类现象我们通常要求每个进程对临界中的代码或数据的操作必须是互斥的或者是原子的,即一次只能允许最多一个进程在临界区内执行。
- 临界资源:多个线程都能访问到的资源就是临界资源
- 临界区:访问临界资源的代码区域被称之为临界区
通过上面的问题,我们可以总结出互斥并发控制问题应该满足的条件:
- 一次至多一个进程能够在临界区内
- 一个在非临界区中止的进程不能影响其他进程
- 一个进程留在临界区中的时间必须时有限的
- 不能强迫一个进程无限的等待进入临界区
- 当没有进程在临界区的时候,任何要进入临界区的进程必须能够立即进入
- 对相关进程的执行在临界区中时,任何需要进入临界区的进程必须能够立即进入
3.2.1互斥锁的原理
互斥锁的底层是一个互斥量,互斥量的本质是计数器,计数器的取值只能为0或者1;计数器在0,1转换时必须是原子性的。0代表不能获取互斥锁,1代表可以获取互斥锁。
1.本质:互斥锁的本质其实是计数器,其实就是互斥量。计数器的取值只能为0或者1,单线程获取互斥锁的时候计数器当中的值为0,表示当前线程或起不到互斥锁,也就是没有获取互斥锁,就不要去获取临界资源了,如果计数器当中的值为1,表示当前获取到互斥锁,也就一位置可以访问临界资源。
2.计数器当中的值如何保持原子性(加锁过程):为什么计数器当中的值从0变成1,或者从1变成0是原子操作?因为获取锁资源的时候(加锁的时候)首先寄存器当中的值全部直接赋值为0,然后再将寄存器当中的值和计数器当中的值进行交换,最后判断寄存器当中的值,得出加锁结果。当寄存器当中的值为1时,则表示可以加锁;当寄存器当中的值为0时,则表示不可以加锁。
3.2.2互斥锁接口
1.初始化接口和销毁接口
动态初始化,必须销毁互斥锁,否则就会内存泄漏。
//动态初始化接口
int pthread_mutex_init(pthread_mutex_ *restrict mutex,
const pthread_mutexattr_t *restrict attr);
//销毁接口
int pthread_mutex_destory(pthread_mutex_t *mutex);
参数:
- pthread_mutex_t:互斥锁类型
- mutex:传递一个互斥锁变量的地址给该参数
- attr:一般传递NULL,采用默认属性
静态初始化不需要进行销毁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITALIZER;
PTHREAD_MUTEX_INITALIZER;是一个宏,时typedef的一个结构体。
2.加锁接口
- 阻塞加锁:
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数和返回:
- mutex:传入互斥锁变量的地址
- 如果mutex当中的计数器值为1,则pthread_mutex_lock接口返回了,表示加锁成功,同时计数器当中的值会被变更为0
- 如果mutex当中的计数器值为0,则pthread_mutex_lock接口阻塞了,pthread_mutex_lock就靠没有返回,阻塞在该函数内部,直到加锁成功
- 非阻塞加锁:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
非阻塞加锁接口返回及含义:
- 当互斥所变量当中的计数器的值为1,则加锁成功
- 当互斥锁变量当中的计数器值为0,则会返回,但是要知道加锁并没有成功,也就不会去访问临界区资源
- 一般情况下非阻塞接口要搭配循环来使用
- 带有超时时间的加锁接口:
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex const struct timespac *restrict abs_timeout);
参数和返回:
- 带有超时时间的接口,也就意味着当不能直接获取互斥锁的时候,会等待abs_timeout时间
- 如果在这个时间加锁成功了,直接返回,不需要再继续等待剩余的时间,并且表示加锁成功
- 如果超过该时间,必须返回,但是表示加锁失败了,需要循环加锁
3.解锁接口
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- mutex:传递一个互斥锁变量的地址给该参数
4.代码实现
在这里我们要注意三点:
- 应该先进行互斥锁的初始化,在创建线程。因为线程一旦创建就会去执行他的入口函数而来不及初始化互斥锁。
- 加锁的位置必须是访问临界资源前进行加锁。
- 在所有可能导致线程退出的地方进行解锁,否则这个退出线程最终不会进行解锁。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#define THREADNUM 4 //四个线程
int g_tickets = 1000;
//定义一个全局的互斥锁变量
pthread_mutex_t g_lock;
//解锁
void* GetTicketStart(void* arg)
{
while(1)
{
//加锁
pthread_mutex_lock(&g_lock);
if(g_tickets > 0)
{
printf("i am %p, i have ticket %d\\n", pthread_self(), g_tickets);
g_tickets--;
}
else
{
pthread+mutex_unlock(&g_lock);
break;
}
pthread+mutex_unlock(&g_lock);
}
return NULL;
}
int main(){
//初始化互斥锁
pthread_mutex_init(&g_lock, NULL);//动态初始化
pthread_t tid[THREADNUM];
for(int i = 0; i < THREADNUM; i++)
{
int ret = pthread_create(&tid[i], NULL, GetTicketStart, NULL);
if(ret < 0)
{
perror("pthread_create");
return 0;
}
}
for(int i = 0; i < THREADNUM; i++)
{
pthread_join(tid[i], NULL);
}
pthread_mutex_destroy(&g_lock);//销毁互斥锁
return 0;
}
四.死锁
4.1死锁的现象
4.1.1造成死锁现象的情况
死锁是由并发执行的进程对共享资源的占有和请求所造成的。一组进程处于死锁状态指的是该组中的每一个进程都在等待被另一个进程所占有的,不能抢占的资源。
- 如果执行流加载完毕后不进行解锁,会造成死锁现象
- 线程A获取了1锁,线程B获取了2锁,同时线程A还想获取2锁,线程B还想获取1锁,这时线程A就拿着1锁阻塞在了等待获取2锁的代码上面;线程B就拿着2锁,阻塞在了等待1锁的代码上面,所以上方都阻塞了,会造成死锁现象。
4.1.2死锁现象的代码
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
pthread_mutex_t g_lock1;//1锁
pthread_mutex_t g_lock2;//2锁
void* MyThreadStartA(void* arg){
(void)arg;
//线程A拿到1锁
pthread_mutex_lock(&g_lock1);
sleep(3);//防止直接拿到2锁
pthread_mutex_lock(&g_lock2);
return NULL;
}
void* MyThreadStartB(void* arg){
(void)arg;
//线程B拿到2锁
pthread_mutex_lock(&g_lock2);
sleep(3);//防止直接拿到1锁
pthread_mutex_lock(&g_lock1);
return NULL;
}
int main(){
//锁的初始化
pthread_mutex_init(&g_lock1, NULL);
pthread_mutex_init(&g_lock2, NULL);
pthread_t tid;
int ret = pthread_create(&tid, NULL, MyThreadStartA, NULL);
if(ret < 0){
perror("pthread_create");
return 0;
}
ret = pthread_create(&tid, NULL, MyThreadStartB, NULL);
if(ret < 0){
perror("pthread_create");
return 0;
}
while(1){
sleep(1);
}
pthread_mutex_destroy(&g_lock1);
pthread_mutex_destroy(&g_lock2);
return 0;
}
4.2死锁的必要条件
死锁发生的四个必要条件:
- 互斥:一个资源一次只能被一个进程所使用,如果有其他进程请求该资源,那么请求进程必须等待,直到改资源被释放。
- 占有且等待:一个进程请求资源得不到满足而等待的时候,不释放已经占有的资源。
- 不可抢占:一个进程不能强行从另一个进程那里抢夺资源,即已经被占有的资源只能由占有进程自己来释放。
- 循环等待:在资源分配图中,存在一个循环等待链,其中每一个进程分别等待他的前一个进程持所持有的资源。
这四个条件合在一起是死锁的必要条件,但并不充分,也就是说满足这些条件并不一定会导致死锁。
4.3解决死锁的方法
4.3.1预防死锁
- 破坏请求条件:资源的一次性分配,这样就不会再有请求了
- 破坏保持条件:只要有一个资源得不到分配,这个进程所有资源都不予分配
- 破坏不可剥夺条件:当某个进程获取到某个资源,但是得不到其他资源,则将现有的资源进行释放
- 破坏环路等待条件:每个资源按增序请求资源,释放相反
4.3.2解除死锁
当发现有进程发生死锁,兵应该立即将其从死锁状态中释放出来,常常采用以下的方法:
- 从其他进程中剥夺足够的资源给发生死锁的进程,用来解决死锁
- 可以直接讲发生死锁的进程进行撤销,或者将撤销代价较小的进程撤销,直到有资源可以分配,死锁状态消除为止。
五.生产者与消费者模型
5.1 123规则
所谓123规则指的是1个线程安全队列,2种角色的线程,3种关系。
- 1个线程安全队列:队列的特性就是先进先出,线程安全就是需要保证在同一时刻队列中的元素只有一个执行流去访问
- 2种角色的进程:生产者和消费者
- 3种关系: 生产者与消费者互斥,消费者与消费者互斥,生产者与消费者同步+互斥
5.2生产者与消费者模型代码
#include <stdio.h>
#include <unistd.h>
#include <iostream>
#include <queue>
#define THREAD_NUM 2
//线程安全的队列
class RingQueue{
public:
RingQueue(){
capacity_ = 10;
//初始化
pthread_mutex_init(&lock_, NULL);
pthread_cond_init(&cons_, NULL);
pthread_cond_init(&prod_, NULL);
}
~RingQueue(){//析构
pthread_mutex_destroy(&lock_);
pthread_cond_destroy(&cons_);
pthread_cond_destroy(&prod_);
}
//生产者线程调用
void Push(int data){
pthread_mutex_lock(&lock_);//加锁
while(que_.size() >= capacity_){
//如果此时空间不足够,就让他在生产者中条件变量中去等待
pthread_cond_wait(&prod_, &lock_);
}
que_.push(data);//他是一个临界区资源需要进行加锁
pthread_mutex_unlock(&lock_);//解锁
pthread_cond_signal(&cons_);//通知消费者进行消费
}
//消费者线程使用
void Pop(int* data){
pthread_mutex_lock(&lock_);//在这里也需要进行加锁
while(que_.empty()){
pthread_cond_wait(&cons_, &lock_);
}
*data = que_.front();//拿到队列的首元素
que_.pop();
printf("i am consumer %p, i consume %d\\n", pthread_self(), *data);
pthread_mutex_unlock(&lock_);//解锁
pthread_cond_signal(&prod_);//通知生产者进行生产
}
private:
std::queue<int> que_;
size_t capacity_;//上限
pthread_mutex_t lock_;//互斥锁
//定义条件变量
pthread_cond_t cons_;//生产者
pthread_cond_t prod_;//消费者
};
//要解决生产线程和消费线程如何进行同步
void* ConsumeStart(void* arg){
RingQueue* rq = (RingQueue*)arg;
while(1){
int data;
rq->Pop(&data);
}
return NULL;
}
int g_data = 1;
pthread_mutex_t g_lock;
void* ProductStart(void* arg){
RingQueue* rq = (RingQueue*)arg;
while(1){
rq->Push(g_data);
//在这里两个生产者加的时全局变量,而并非是自己函数栈帧当中的临时变量。但是当两个线程要同时访问这个全局变量,此时就会有线程安全问题,可能会造成二义性,因此我们就要对这个临界资源进行加锁保护
//lock
pthread_mutex_lock(&g_lock);
g_data++;
//unlock
pthread_mutex_unlock(&g_lock);
//sleep(1);
}
return NULL;
}
int main(){
pthread_mutex_init(&g_lock, NULL);
RingQueue* rq = new RingQueue();
if(rq == NULL){
return 0;
}
pthread_t cons[THREAD_NUM], prod[THREAD_NUM], prod1[THREAD_NUM];
for(int i = 0; i < THREAD_NUM; i++){
int ret = pthread_create(&cons[i], NULL, ConsumeStart, (void*)rq);//消费者
if(ret < 0){//此时创建失败
perror("pthread_create");
return 0;
}
ret = pthread_create(&prod[i], NULL, ProductStart, (void*)rq);//生产者
if(ret < 0){
perror("pthread_create");
return 0;
}
ret = pthread_create(&prod1[i], NULL, ProductStart, (void*)rq);
if(ret < 0){
perror("pthread_create");
return 0;
}
}
for(int i = 0; i < THREAD_NUM; i++){
pthread_join(cons[i], NULL);
pthread_join(prod[i], NULL);
pthread_join(prod1[i], NULL);
}
pthread_mutex_destroy(&g_lock);
return 0;
}
六.信号量
现在我们常用信号量是POSIX标准信号量,他的本质是资源计数器+PCB等队列。
6.1POSIX完成两件事情(互斥与同步)
信号量可以完成线程与线程之间的同步与互斥,也可以完成进程与进程之间的同步于互斥。
- 1.互斥:(当多个线程要获取信号量的时候,就会对信号量当中的计数器进行减一操作)初始化信号量的资源计数器为1,表示当前只有一个资源,意味着只有一个线程在同一时刻可以获取信号量(保证互斥的情况下):
如上图,如果线程A和线程B想要访问临界资源就必须先先获取到信号量,而在此保证互斥的前提就是将信号量中的资源计数器置为1,当一个线程去获取信号量的时候,资源计数器就会进行减一操作。此时去判断资源计数器当中的值是否大于等于0的,如果是大于等于0,则表示我们可以进行访问这个临界资源。当线程B获取信号量时,在进行减一操作,此时资源计数器中的值小于0,那么此时线程B就会被阻塞在信号量的等待接口当中,然后被放在PCB等待队列里面进行等待。这就是资源计数器被初始化为1的情况。
- 2.同步:资源计数器在初始化的时候,就不必初始时必须设置为1了。当执行流想要访问临界资源的时候,首先要获取信号量。如果信号量中的资源计数器大于0,则表示还有多少可以被使用,等于0则表示没有资源可以被使用,如果小于0,则表示还有多少进程在等待资源。
注意1:如果计数器的值为负时表示当前还有计数器的绝对值个执行流在等待。
注意2:当释放信号量的时候,会对信号量当中的计数器进行加一操作此时当计数器进行加一操作完成之后计数器还未负数或者为0,此时需要通知PCB等待队列当中的执行流;当计数器嫁衣操作之后为正数,则不需要通过PCB等待队列。
6.2对信号量的操作
1.初始化信号量()
int sem_init(sem_t *sem, int pshared,unsigned int value);
- sem_t:信号量的类型
- sem:传入待要初始化的信号量
- pshared:表示信号量到底是用于进程间还是线程间,如果是0,则表示是线程间;如果是1,表示用到进程间
- value:表示初始化的资源数量
2.销毁信号量(释放接口)
int sem_destory(sem_t *sem);
sem:传入待要销毁的接口
3.等待信号量(等待接口)
调用等待接口之后,会对信号量中的资源计数器进行减一操作
int sem_wait(sem_t *sem);//阻塞接口
int sem_trywait(sem_t *sem);//非阻塞接口
int sem_timedwait(sem_t *sem, const struct timespac *abs_timeout);//带有超时时间的接口
sem:传入信号量
注意:调用改接口的执行流会对计数器进行减一操作,如果说加一之后计数器的值大于等于0,则表示可以访问临界资源,意味着sem_wait函数会返回,如果说减一操作之后计数器的值是小于0的,调用改接口的执行流被阻塞,该执行流被放到PCB等待队列当中。
4.发布信号量(发布接口)
发布接口调用之后,会对信号量中的资源计数器进行加一操作
int sem_post(sem_t *sem);
以上是关于linux--线程的主要内容,如果未能解决你的问题,请参考以下文章
JUC并发编程 共享模式之工具 JUC CountdownLatch(倒计时锁) -- CountdownLatch应用(等待多个线程准备完毕( 可以覆盖上次的打印内)等待多个远程调用结束)(代码片段