一、线程的基本概念
线程(thread):在一个程序里运行的一个执行流就称为一个线程。再准确地说就是“一个进程内部的一个执行流(控制序列)即一个线程”。一个进程内至少有一个线程。线程在进程内运行,本质就是在进程地址空间内运行。
Linux中的进程:线程一定是由系统所管理的,秉持“先描述,再组织”的原则,OS 内核中一定有描述线程的数据结构。在 Linux 系统中,线程与进程共用一套结构体进行描述,即 task_struct 结构体。在 CPU 看来,线程的 PCB 更加轻量化,所以在 Linux 系统中,线程也可称为轻量化进程。
轻量化进程(Lightweight Process,LWP):是一种比传统进程更轻量级的执行单元,在操作系统中用于实现多线程编程和并发编程。轻量化进程在同一个进程内部创建的,它们共享该进程的地址空间和其他资源,但拥有独立的栈空间和寄存器状态。
主线程与新线程:创建进程后的第一个线程成为主线程,再创建的线程为新线程。
线程与进程的区别:
- 资源占用:进程拥有独立的资源,而线程共享进程的资源。
- 创建和销毁开销:创建和销毁线程的开销通常比进程小。
- 通信和同步:进程间通信需要使用特定的机制,如管道、消息队列等,而线程可以直接共享内存进行通信。线程间同步更加简单,使用线程同步工具如互斥锁、条件变量等即可。
- 线程的切换开销比进程小,因此在某些场景下,线程的并发性能更好。
线程与进程的关系:
二、线程控制
1.线程库
在 Linux 内核中并没有直接定义线程的描述结构体和相关操作方法,因此采用库的方式,提供给用户进行线程控制的方法接口——POSIX线程库(Portable Operating System Interface for Unix):是一套用于多线程编程的标准接口,旨在提供跨平台、可移植的线程操作功能。它定义了一组函数、数据类型和常量,使开发人员可以在符合POSIX标准的操作系统上编写可移植的多线程应用程序。
Linux 中的线程操作,都在线程库中维护。
要使用线程库,需要引入 <pthread.h> 头文件。
链接使用线程库的程序时,需要使用“-lpthread”命令项。
2.线程的创建与终止
线程创建与线程ID:
在调用 pthread_create 函数
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void * (*start_routine)(void*), void *arg);参数:thread:线程创建成功后,会返回线程IDattr:设置线程的属性,一般填入 NULL 表示默认属性start_routine:指向一个函数的指针,表示新线程将要执行的函数。该函数必须返回 void * 类型,接受一个 void * 类型的参数arg:传给 start_routine 函数的参数返回值:创建线程成功时返回 0,失败时返回一个非零的错误码,可以通过全局变量 errno 查看具体错误信息。
成功创建一个线程之后,pthread_create 会将线程的 ID 返回到 thread 中。以下方式可以查看线程的 ID
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
void *rout(void *arg){
for(;;){
printf("I am thread : %d\n", arg);//新线程运行并打印线程ID
sleep(1);
}
}
int main()
{
pthread_t tid;
if (pthread_create(&tid, NULL, rout, &tid) != 0){
perror("pthread_create:");
exit(-1);
}
for(;;){
printf("I am main thread\n"); //主线程运行
sleep(1);
}
}
运行效果如下
程序运行的同时用 $ ps -aL || grap mytest 指令查看正在运行的线程
其中,PID 是线程所在的进程的进程 ID,LWP 是线程(轻量化进程)PCB 的 PID,LWP == PID 的线程是主线程,其他为新线程。
线程ID是一个被定义为无符号长整型数的 pthread_t 类型,用来唯一标识一个线程。用以实现对具体某一个线程的控制。在 Linux 中,线程ID本质就是一个进程地址空间上的一个地址。
线程库提供了 pthread_self 函数,可以获取线程自身的ID:
pthread_t pthread_self(void);
线程的终止
线程是在进程中运行的,所以当整个进程终止后,其中的所有线程也都会终止。
如果想只终止某个线程而不终止整个进程,可以有以下三种方式:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2. 线程可以调用pthread_ exit终止自己。
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit:
void pthread_exit(void *value_ptr);
参数
value_ptr:一个指针,用于传递线程的退出状态给父线程。可以是任意类型的指针,通常用来传递线程的返回值或其他信息。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
pthread_cancel:
int pthread_cancel(pthread_t thread);
参数
thread:线程ID返回值:
成功返回0;失败返回错误码
3.线程的等待与分离
线程等待(join)
线程退出时会存在一些问题:1.已经退出的线程,其空间没有被进程所释放;2.再创建线程时不会复用之前退出的线程空间。因此需要在主线程中对新线程进行线程等待。
pthread_join:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:要等待的线程的ID
value_ptr:用于接收线程的退出状态的指针
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:
1. 线程通过 return 返回,value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。
2. 线程被别的线程调用 pthread_ cancel 异常终止,value_ ptr 所指向的单元里存放的是常数PTHREAD_ CANCELED。
3. 线程自己调用 pthread_exit 终止,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
4. 线程对终止状态不感兴趣,可以传 NULL 给 value_ ptr 参数。
线程分离(detach)
在 POSIX 线程中,一个线程可以处于两种状态中的一种:joinable 和 detached。
1.joinable 状态:默认情况下,线程处于 joinable 状态,它的退出状态不会被立即回收,需要其他线程调用 pthread_join 函数来等待该线程结束并获取其退出状态。在这种状态下,线程的资源会一直保留,直到其他线程调用 pthread_join 函数。
2.detached 状态:当一个线程处于 detached 状态时,它的退出状态会在线程结束时自动被回收,无需其他线程调用 pthread_join 函数。在这种状态下,线程结束后,系统会立即回收其资源,不需要等待线程。
pthread_detach:
int pthread_detach(pthread_t thread);
用来将一个线程设置为分离状态(detach)。
也可以通过在一个新线程的线程调用函数内部调用 pthread_detach(pthread_self()) 的方式对自身设置分离状态。
void *thread_run(void *arg){
pthread_detach(pthread_self());
printf("%s\n", (char *)arg);
return NULL;
}
int main(void)
{
pthread_t tid;
if (pthread_create(&tid, NULL, thread_run, (void *)"thread1 run...") != 0){
printf("create thread error\n");
return 1;
}
int ret = 0;
sleep(1); // 要给点时间,让线程先分离,再执行等待
if (pthread_join(tid, NULL) == 0){
printf("pthread wait success\n");
ret = 0;
}
else{
printf("pthread wait failed\n");
ret = 1;
}
return ret;
}
运行结果自然就是,线程分离,等待失败!
三、线程互斥与互斥量
二者关系:线程互斥是一种要实现的现象,互斥量是实现线程互斥的工具。
1.线程互斥
大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。但多个线程并发地操作共享变量,由于对变量的操作是非原子性的,所以会存在一些问题。
对一个变量的操作,在汇编代码中需要三条指令(三个动作):
load:将变量从内存中加载到线程寄存器中
updata:更新寄存器中的值,如执行-1操作
store:将更新后的值,从寄存器写回变量的内存地址
问题就出在此,由于操作系统负责线程的调度,当多个线程并发操作一个共享变量时,可能一个线程的一次操作变量动作还没结束,就被操作系统调度走,在这期间其他线程已经对该变量操作了数次,当开始的线程再次被调度并执行完操作变量的动作后,这期间内其他线程对该变量的操作都将没有意义。
将上文“共享变量”的概念引申一下,就是临界资源:多线程执行流共享的资源。而每个线程内部,访问临界资源的代码(块),就是临界区。
为了解决线程对临界资源的操作问题,就需要让线程之间互斥:互斥保证任何时刻有且只有一个执行流进入临界区,访问临界资源,对临界资源起保护作用。
而要实现线程互斥,需要做的操作就是对线程的临界区代码进行加锁,这个“锁”就是互斥锁,也叫互斥量(mutex)。
被加锁的代码(块)就是原子性的,不会被任何调度机制打断,在外界看来只有两态,要么执行完成,要么根本不执行。
2.互斥量(mutex)
互斥量接口函数
初始化互斥量:
静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
互斥量的生命周期与其所在的作用域相关,当程序离开该作用域时,互斥量会被销毁并释放对应的内存,无需手动释放。
动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *attr);
参数:
mutex:指向需要初始化的互斥锁变量的指针
attr:指向包含互斥锁属性的结构体指针,通常传入 NULL 来使用默认属性
动态分配的互斥量,在确定完全使用完,保证不会再用时,需要手动调用 pthread_mutex_destroy 函数进行销毁:
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;失败返回错误码
当多个线程并发运行时,某一线程执行加锁操作会出现以下两种情况:
1. 互斥量处于未上锁状态,该函数会将互斥量锁定,同时返回0
2. 其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么 pthread_ lock 调用会进入阻塞状态而不再向下执行(执行流被挂起),等待互斥量解锁。
要理解上文所提到的“竞争”,就需要理解互斥量实现的原理。
3.互斥量的原理与死锁问题
加锁与解锁的原理
为了实现互斥锁操作,大多数体系结构都提供了 swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个执行流的交换指令执行时另一个执行流的交换指令只能等待。现在我们把 lock 和 unlock 的伪代码改一下,便于理解。
上图为原理伪代码,并非真正的原理代码。
基本思想就是,加锁时,执行流先在寄存器中创建一个不等于互斥量的变量,再将该变量与存放互斥量的空间地址进行交换。此时寄存器中是互斥量所在地址中的值,原先存放互斥量的地址,现在存放的就是刚刚交换过去的值。之后再判断当前交换来的寄存器中的值是否等于真正的互斥量,如果等于则说明该互斥量之前未被其他线程占用,上锁成功,可以继续向下执行;若不等于则说明该互斥量已经被别的执行流所申请,则立刻返回不再向下执行。
加锁成功后的解锁操作,就是将当前寄存器中存放的真正的互斥量,再放回到原先存放互斥量的地址中去。
死锁问题(Deadlock)
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁的四个必要条件:
互斥条件:一个资源每次只能被一个执行流使用
请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
要避免死锁问题,就需要破坏四个必要条件中的一个。或者利用避免死锁的算法:死锁检测算法;银行家算法等。以及线程同步。
四、线程同步与条件变量
二者关系:线程同步是一种要实现的现象,条件变量是实现线程同步的工具。
当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。例如,当一个线程访问队列时,发现队列为空,它只能挂起等待,而不能阻塞等待,当同时出现很多这种情况时,CPU 资源就会很大程度被浪费。这种问题称为饥饿问题,为了解决这种问题,就需要实现线程同步,就需要用到条件变量。
线程同步解决什么问题:
竞态条件(Race Condition):当多个线程同时访问共享资源时,由于执行顺序不确定或操作不是原子性的,可能导致数据的混乱和不一致性。线程同步可以避免竞态条件的发生,确保多个线程对共享资源的访问是有序的。
死锁:线程同步可以通过合适的加锁和解锁机制来避免死锁的发生,确保线程之间的协作和资源的正确释放。
数据一致性(Data Consistency):在多线程环境下,如果多个线程同时操作共享数据,可能导致数据的不一致性。线程同步可以保证多个线程对共享数据的访问是有序的,避免数据的不一致性问题。
性能优化:通过线程同步机制,可以合理地控制多个线程之间的执行顺序和资源访问,提高程序的并发性能和效率。
资源争夺:多个线程需要竞争有限的资源时,线程同步可以协调和管理资源的分配和释放,避免资源争夺问题导致程序运行异常或性能下降。
条件变量
初始化:
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *attr);
参数:
cond:要初始化的条件变量
attr:设置模式,一般 NULL
销毁:
int pthread_cond_destroy(pthread_cond_t *cond);
等待条件满足:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
参数:
cond:要等待的条件变量
mutex:条件变量所配合的互斥量
条件变量的使用规范
等待条件变量
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);
简单同步案例程序:
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
pthread_mutex_t mutex;
pthread_cond_t cond;
int flag = 0;
void *thread1(void *arg){
while(1){
pthread_mutex_lock(&mutex);
while(!flag) pthread_cond_wait(&cond, &mutex);
flag = 0;
printf("thread 1 runing ...\n");
pthread_mutex_unlock(&mutex);
}
}
void *thread2(void *arg){
while(1){
pthread_mutex_lock(&mutex);
flag = 1;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);
sleep(1);
}
}
int main(void)
{
pthread_t t1, t2;
pthread_cond_init(&cond, NULL);
pthread_mutex_init(&mutex, NULL);
pthread_create(&t1, NULL, thread1, NULL);
pthread_create(&t1, NULL, thread2, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
运行结果