Linux多线程
文章目录
一、线程
1 何为线程
在一个程序里面的一个执行流就称为线程,一个进程内部至少有一个执行线程。
线程在进程内部运行,本质是在进程地址空间内运行;在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化,通过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
进程与线程关系:
2 线程优点
- 创建一个新线程的代价要比一个新进程要小的多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用资源要比进程少很多
- 线程可充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
3 线程缺点
性能的损失:
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。健壮性降低:
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,即线程之间是缺乏保护的。缺乏访问控制:
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响编程难度提高:
编写与调试多线程程序比单线程程序困难的多
4 线程异常
- 单个线程崩溃,会导致整个进程都会崩溃
如,单线程执行了除0和野指针的问题的话就会导致该线程崩溃,进而该进程就会崩溃。- 线程是进程的执行分支,线程出现异常,进程也会出现异常,进而就会触发信号机制,终止进程,进程终止后该进程中的所有线程也会退出。
5 线程使用场景
- CPU密集型
- IO密集型
二、进程与线程
2.1 区别与联系
- 进程:资源分配的最小单位
- 线程:资源调度的最小单位
进程下的所有线程共享进程的数据,但是线程也有自己独有的一些数据。
线程共享进程的数据:
- 文件描述符表
- 每种信号的处理方式(SIG_IGN、SIG_DFL 或者 自定义的一些信号处理函数)
- 当前的工作目录
- 用户id和组id
线程自己独有的数据:
- 线程ID
- 一组寄存器
- 栈
- 堆
- errno
- 信号屏蔽字
- 调度优先级
Linux下线程的堆和栈是独有的。每个线程都有自己独立的堆和栈。堆用于存储动态分配的内存,而栈用于存储线程的局部变量、函数调用和返回地址等信息。
进程与线程关系:
三、线程控制
POSIX线程库(POSIX Threads)是POSIX标准的一部分,定义了一套API用于创建和操作线程。
绝大多数函数名字以pthread_
打头,头文件<pthread.h>
,链接该库时使用-lpthread
选项。
3.1 创建线程
//函数原型:
int pthread_create(pthread_t* thread,const pthread_attr_t* attr,void* (*start_routine)(void*),void* arg);
//参数:
thread:函数返回的线程ID
attr:设置线程的属性,为 NULL 表示默认属性
start_routine:函数地址,表示线程启动时要执行的函数
arg:传递给线程执行函数的参数
//返回值:
成功 ------> 返回0
失败 ------> 返回错误码
函数的错误检查:
- 一些传统的函数是,成功返回0,失败返回-1,并且堆全局变量errno赋值以表示错误。
- pthreads函数出错时不会设置全局变量errno,而是将将错误代码通过返回值进行返回,但是大部分的POSIX函数都会设置全局变量errno。
- pthreads也提供了线程内的errno变量,以此来支持其它使用errno的代码。因为读取pthreads函数返回值要比读取线程内的errno变量的开销要小,所以最好通过返回值来判定pthreads函数是否出错。
线程ID及进程地址空间布局:
pthread_ create
函数会产生一个线程ID,存放在第一个参数所指向的地址中。该线程ID和前面说的线程ID不是一回事。前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create
函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。线程库NPTL提供了pthread_ self
函数,可以获得线程自身的ID。
//获取某一线程的ID
pthread_t pthread_self(void);
pthread_t
到底是什么类型?这取决于其相对应的实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
//pthread1.c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<pthread.h>
void* rout(void* arg)
{
for(;;)
{
printf("I am thread 1\n");
sleep(1);
}
}
int main()
{
pthread_t tid;
int ret=pthread_create(&tid,NULL,rout,NULL);
if(ret!=0)
{
fprintf(stderr,"pthread_create:%s\n",strerror(ret));
exit(EXIT_FAILURE);
}
for(;;)
{
printf("I am main thread\n");
sleep(1);
}
return 0;
}
3.2 线程终止
终止某个线程而非终止整个进程:
- 从线程函数
return
,此方法对主线程并不适用,从main函数return相当于调用exit- 线程可以调用
pthread_exit
来终止自己- 一个线程可以调用
pthread_cancel
终止同一进程中的另一个进程
//作用:线程终止
//函数原型:
void pthread_exit(void* value_ptr);
//参数:
value_ptr:不能指向一个局部变量
//返回值:
无返回值,与进程一样,线程结束时无法返回其调用者
注意:
pthread_exit或者
return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上进行分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
//作用:取消一个执行中的线程
//函数原型:
int pthread_cancel(pthread_t thread);
//参数:
thread:线程ID
//返回值:
成功 ------> 返回0ig
失败 ------> 返回错误码
3.3 线程等待
对于一个退出了的线程,其空间并没有被释放,任然存在在进程地址空间中,创建新的线程并不会复用刚才退出线程的地址空间。
所以这就需要线程等待,等待指定的线程退出,获取退出返回值,回收未被完全释放的资源。
//作用:等待指定线程结束
//函数原型:
int pthread_join(pthread_t thread,void* value_ptr);
//参数:
thread:线程ID
value_ptr:指向一个指针,该指针指向线程的返回值
//返回值:
成功 ------> 返回0ig
失败 ------> 返回错误码
调用该函数的线程将挂起等待,直到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参数。
// pthread2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread1(void *arg)
{
printf("thread 1 returning ... \n");
int *p = (int *)malloc(sizeof(int));
*p = 1;
return (void *)p;
}
void *thread2(void *arg)
{
printf("thread 2 exiting ...\n");
int *p = (int *)malloc(sizeof(int));
*p = 2;
pthread_exit((void *)p);
}
void *thread3(void *arg)
{
while (1)
{ //
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
void *ret;
// thread 1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int *)ret);
free(ret);
// thread 2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int *)ret);
free(ret);
// thread 3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED)
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
return 0;
}
3.4 分离线程
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
将线程的分离属性设置为detach,表示线程在退出之后自动释放所有资源,那么这种线程就不需要被等待。
//作用:分离线程
//函数原型:
int pthread_detach(pthread_t thread);
//作用:自己分离自己
//函数原型:
pthread_detach(pthread_self());
其中一个线程不能既是joinable,又是分离的。
四、线程安全
在多线程程序中,往往会涉及到对共享资源的操作,这就会产生数据的二义性问题,而如何控制对共享资源的操作不会产生二义性呢?这就涉及到互斥和同步。
- 互斥
含义:同一时间执行流对资源访问的唯一性来保证访问安全
实现:互斥锁、读写锁、自旋锁等- 同步
含义:条件控制,让多执行流对资源的获取更加合理
实现:条件变量、信号量
4.1 互斥-互斥锁
互斥锁,实现对共享资源的唯一访问。
- 本质:一个0/1计数器,0/1来标记资源的访问状态,0表示不可访问,1表示可以访问
- 原理:在访问资源之前进行加锁操作,通过状态判断资源是否可以访问,不可访问则进入阻塞状态,直到资源为可访问状态;在访问资源之后进行解锁操作,将资源状态置为可访问状态,唤醒其它阻塞的线程。
互斥量接口:
//作用:初始化互斥锁
//原型:
//1.静态分配
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
//2.动态分配
int pthread_mutex_init(pthread_mutex_t* mutex,pthread_mutexattr* attr);
//参数:
mutex:要初始化的互斥量
attr:互斥锁变量属性,通常置NULL
//阻塞加锁
int pthread_mutex_lock(pthread_mutex_t* mutex);
//非阻塞加锁
int pthread_mutex_trylock(pthread_mutex_t* mutex);
//解锁
int pthread_mutex_unlock(pthread_mutex_t* mutex); 解锁
//销毁互斥锁
int pthread_mutex_destroy(pthread_mutex* mutex);
例子:售票系统售票。
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 10;
pthread_mutex_t mutex;
void* route(void* arg)
{
char* id = (char *)arg;
while (1)
{
//对共享资源操作前进行上锁
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
//对共享资源操作后进行解锁
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, route, "thread 1");
pthread_create(&t2, NULL, route, "thread 2");
pthread_create(&t3, NULL, route, "thread 3");
pthread_create(&t4, NULL, route, "thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
pthread_mutex_destroy(&mutex);
return 0;
}
死锁
- 死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
- 条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
- 避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
4.2 同步-条件变量
条件变量,条件控制,满足条件则唤醒线程,条件不满足则阻塞线程。
- 本质:提供一个pcb等待队列和唤醒和阻塞线程的接口
- 原理:线程1对资源获取条件进行判断,若线程不符合资源获取条件,则调用阻塞接口阻塞线程;线程2促使资源获取条件满足之后,通过唤醒接口唤醒阻塞的接口。
条件变量接口:
//初始化条件变量
//1.静态分配
pthread_cond_t cond=PTHREAD_COND_INITIALIZER
//2.动态分配
int pthread_cond_init(pthread_cond_t* cond,const pthread_condattr_t* attr);
//参数:
cond:要初始化的条件变量
attr:互斥锁变量属性,通常置NULL
//等待条件满足(搭配互斥锁一块儿使用)
int pthread_cond_wait(pthread_cond_t* cond,pthread_mutex_t* mutex);
int pthread_cond_timewait(pthread_cond_t* cond,pthread_mutex_t* mutex,struct timespec* t);
//参数:
cond:要在这个条件变量上等待
mutex:互斥量
//唤醒等待
int pthread_cond_broadcast(pthread_cond_t* cond); 至少唤醒阻塞队列中一个线程
int pthread_cond_signal(pthread_cond_t* cond); 唤醒阻塞队列中的所有线程
//销毁条件变量
int pthread_cond_destroy(pthread_cond_t* cond);
思考🤔:为什么pthread_ cond_ wait 需要互斥量?
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据。
4.3 同步-POSIX信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
信号量:实现进程或线程间的同步与互斥。
- 本质:计数器
- 操作:通过计数器对资源进行计数,通过计数器实现资源获取的同步
P操作:对计数器-1操作,若小于0则表示无资源,则线程阻塞
V操作:对计数器+1操作,唤醒一个阻塞中的线程 - 同步实现:初始化计数器为资源数量,线程A获取资源之前进行P操作,线程B产生一个资源之后进行V操作
- 互斥实现:初始化计数器为1,线程访问资源进行P操作,访问资源完毕后进行V操作
信号量接口:
#include <semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value);
//参数:
pshared: 0 表示线程间共享,非零 表示进程间共享
value:信号量初始值
//销毁信号量
int sem_destroy(sem_t* sem);
//等待信号量(P操作)
//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t* sem); 阻塞接口
int sem_trywait(sem_t* sem); 非阻塞接口
//发布信号量(V操作)
//功能:发布信号量,表示资源使用完毕,可以归还资源了,将信号量值加1
int sem_post(sem_t* sem);
五、线程池
一种线程使用模式。
线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。
线程池的种类:
- 创建固定数量线程池,循环从任务队列中获取任务对象,
- 获取到任务对象后,执行任务对象中的任务接口