本篇博客详细介绍与线程有关的内容,这部分也是笔试面试的重点,需要我们对线程有深刻的理解,尤其是线程的并发运行以及线程同步的控制!接下来,让我们走进线程的世界,去理解线程,使用线程!
目录
一、线程的概念与实现方式
1.1线程的概念
1.2线程的实现方式
1.3进程与线程的区别
1.4 代码中如何实现多线程
二、线程使用
2.1 线程库中的接口介绍
2.1.1 创建线程函数
2.1.2 退出当前线程函数
2.1.3 等待子线程结束函数
2.2 线程并发运行
2.2.1 示例代码1
2.2.2 示例代码2
三、线程同步
3.1 线程同步的概念
3.2 信号量
3.2.1 函数接口介绍
3.2.1.1 信号量初始化函数
3.2.1.2 等待信号量(P操作)
3.2.1.3 释放信号量(V操作)
3.2.1.4 销毁信号量
3.2.2 使用示例
3.2.3 面试题
3.3 互斥锁
3.3.1 函数接口介绍
3.3.1.1 互斥锁初始化函数
3.3.1.2 加互斥锁函数
3.3.1.3 解互斥锁函数
3.3.1.4 销毁互斥锁函数
3.3.2 使用示例
3.4 条件变量
3.4.1 函数接口介绍
3.4.1.1 初始化条件变量
3.4.1.2 唤醒一个等待线程
3.4.1.3 唤醒所有等待线程
3.4.1.4 等待条件变量
3.4.1.5 销毁条件变量
3.4.2 使用示例
3.5 读写锁
3.5.1 函数接口介绍
3.5.1.1 读写锁初始化
3.5.1.2 获取读锁
3.5.1.3 获取写锁
3.5.1.4 销毁读写锁
3.5.1.5 释放锁(读锁或写锁)
3.5.2 使用示例
四、线程安全
4.1 概念
4.2 多线程环境如何保证线程安全
4.3 举例理解
五、线程与fork
5.0 多线程的PID
5.1 多线程中某个线程调用 fork(),子进程会有和父进程相同数量的线程吗?
5.1.1 主线程调用fork()产生子进程
5.1.2 子线程调用fork()产生子进程
5.2 父进程被加锁的互斥锁 fork 后在子进程中是否已经加锁?
六、生产者消费者模型(面试题)
6.1 生产者消费者问题概述
6.2 生产者消费者模型优点
6.3 生产者消费者模型实现
七、面试题总结
一、线程的概念与实现方式
1.1线程的概念
我们前面学过进程,我们知道进程是一个正在运行的程序,那么线程是什么呢?其实,线程就是进程中的一条执行路径,我们之前写的程序(进程),只有一个主函数,程序(进程)是从这里开始运行的,其实这也是线程,它可以被叫做主线程,因此,可以说:进程至少有一个线程,而这个线程就是就是主线程,我们也把这种进程叫单线程程序;一个进程是可以有多个线程的,也叫做多线程程序,并且这些线程(子线程)和主线程是同时并发运行的;进程是从资源分配角度上看,线程是从调度执行角度上看.
并发运行
指的是在一个时间段内,多个任务(线程)交替进行。并发并不意味着这些任务(线程)是同时进行的,而是多个任务(线程)在同一时间段内被调度器交替执行,从而使用户感觉这些任务(线程)在同时进行。并发运行的关键在于任务(线程)之间的切换和协调。
- 例子: 在单核处理器上运行多个应用程序。操作系统通过快速切换任务(线程),使用户感觉多个程序在同时运行。
- 特点: 并发运行强调任务(线程)间的交替执行和资源共享,适用于I/O操作频繁的场景。
并行运行(Parallelism)
指的是在同一时刻,多个任务真正地同时进行。并行运行需要多核处理器或多个处理器来实现,每个任务在独立的核心上运行,从而实现真正的同时执行。
- 例子: 在多核处理器上运行一个可以被分解为多个独立部分的应用程序,如科学计算中的矩阵乘法。
- 特点: 并行运行强调任务的同时执行,适用于计算密集型任务。
1.2线程的实现方式
在操作系统中,线程的实现有以下三种方式:
◼ 内核级线程
◼ 用户级线程
◼ 组合级线程
- 用户级:用户空间有很多线程,但在内核里只有一个线程,所以即使有多个处理器空闲 ,用户空间的多个线程也只能交替使用这一个处理器。
- 内核级:内核空间能够感知到用户空间有多少条线程,假如有俩个处理器空闲,这三条线程中的某俩条就能在同一时刻并行。内核级创建的开销很大。
- 组合(混合):根据处理器的数目,允许在内核中创建多条执行路径,可以和处理器的数目有关联,比如有四个处理器,我们最多让内核中有8/4个线程,让每个线程都能用到处理器。超出这个数目的线程,从用户空间来体现。解决了可以创建多个线程,也能使用多个处理器的问题。
Linux 中线程的实现(面试):
Linux 实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux 把 所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来 表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯 一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和 其他一些进程共享某些资源,如地址空间)。
1.3进程与线程的区别
进程 (Process)
定义:
进程是一个独立的执行单元,具有自己的内存空间和资源。它是操作系统进行资源分配和调度的基本单位。内存空间:
每个进程有自己独立的内存地址空间。一个进程无法直接访问另一个进程的内存。资源开销:
创建和销毁进程的开销较大,因为需要分配独立的内存空间和系统资源。通信:
进程间通信 (Inter-Process Communication, IPC) 比较复杂且耗时,可以通过管道、消息队列、共享内存等方式实现。独立性:
进程之间是相对独立的,一个进程的崩溃通常不会影响到其他进程。
线程 (Thread)
定义:
线程是进程中的一个执行单元。一个进程可以包含多个线程,线程是操作系统调度的最小单位。内存空间:
同一进程内的线程共享进程的内存地址空间和资源,因此它们可以直接访问彼此的内存。资源开销:
创建和销毁线程的开销较小,因为线程共享进程的资源,不需要分配独立的内存空间。同步:
线程间同步比较简单,因为它们共享同一进程的内存空间,可以通过信号量、互斥锁、条件变量、读写锁等方式实现。独立性:
线程之间的独立性较低,一个线程的崩溃可能会导致整个进程的崩溃,因为它们共享同一个内存空间。
并发运行:只有一个处理器(CPU)两个线程交替执行,并行运行:有两个处理器(CPU)两个线程同时运行,并行也是特殊的并发。
例子
- 进程的例子:
- 每个运行中的程序如浏览器、文本编辑器都是一个独立的进程。
- 线程的例子:
- 一个浏览器进程可能包含多个线程,如一个线程负责渲染网页,另一个线程负责处理用户输入。
1.4 代码中如何实现多线程
主线程:就是从main函数的第一行执行到最后一行,想要多个线程,就在写个线程函数thread_fun(),可以跟主线程函数并发执行。想要三个线程,另外两个线程,干同一个事就写一个线程函数,如果让他们干不同的事就要再写两个线程函数。
二、线程使用
2.1 线程库中的接口介绍
线程库提供了丰富的接口来创建、管理和同步线程。不同的操作系统和编程语言提供的线程库接口可能有所不同,但大多数线程库都提供了一些常见的基础功能。下面以POSIX线程库(Pthreads)为例,介绍一些常用的线程库接口,使用线程库需要提前引入线程库头文件。
线程库需要引入的头文件 #include <pthread.h>
2.1.1 创建线程函数
pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
线程创建成功返回 0, 失败返回错误码。
参数说明:
thread
:指向线程标识符的指针,线程的id地址 。attr
:线程属性,通常传递NULL使用默认属性。start_routine
:它是一个函数指针,(此函数必须是返回值为void *,参数也是void * ) 它是指向线程函数的。arg
:传递给线程函数的参数。
示例1: 主线程创建1个子线程,两个线程并发运行,各自循环5次打印字符串。注意:线程库是共享库,编译的时候需要加上库(-l+库名),即:gcc -o test test.c -lpthread
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* fun(void*arg) //子线程函数
{
for(int i=0;i<5;i++)
{
printf("fun hello\n");
}
}
int main() //主线程
{
pthread_t id;
pthread_create(&id,NULL,fun,NULL);//创建线程,creat函数成功后会给线程ID进行初始化。
for(int i=0;i<5;i++)
{
printf("main hello\n");
}
exit(0);
}
运行结果:
第一次运行结果分析:
主线程运行非常快,当主线程运行完,调用exit()退出进程,那么子线程是没有机会再运行的,因此,只打印了主线程的,子线程不会打印!
第一次运行结果分析:
子线程运行的非常快,因此,它是有机会打印出来的,它打印完后主线程也会打印,注意:子线程结束不会影响到整个进程,而主线程结束后,整个进程就会结束,所有的子线程也随之终止!一般都会让主线程活到最后。
修改代码如下:加入睡眠,整个程序运行至少5秒,睡眠1秒的时候,线程会交出CPU的使用权。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* fun(void*arg) //子线程函数
{
for(int i=0;i<5;i++)
{
printf("fun hello\n");
sleep(1); //睡眠1秒,交出CPU使用权
}
}
int main() //主线程
{
pthread_t id;
pthread_create(&id,NULL,fun,NULL);//创建线程,creat函数成功后会给线程ID进行初始化。
for(int i=0;i<5;i++)
{
printf("main hello\n");
sleep(1); //睡眠1秒,交出CPU使用权
}
exit(0);
}
运行结果如下:
可以看到:二者是交替运行的,但是两个的先后顺序是不确定的!也就是每次程序执行的结果是不确定的,这样便失去了控制,这也是线程需要控制的一个问题!
通过上面的结果,我们知道:主线程结束,整个进程都会结束,子线程也不会运行,如下所示:修改主线程的运行次数为2.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* fun(void*arg) //子线程函数
{
for(int i=0;i<5;i++)
{
printf("fun hello\n");
sleep(1); //睡眠1秒,交出CPU使用权
}
}
int main() //主线程
{
pthread_t id;
pthread_create(&id,NULL,fun,NULL);//创建线程,creat函数成功后会给线程ID进行初始化。
for(int i=0;i<2;i++)
{
printf("main hello\n");
sleep(1); //睡眠1秒,交出CPU使用权
}
exit(0);
}
运行结果如下:
可以看到:只要主线程运行两次,子线程就会结束(子线程运行次数少于5次)。
那么如何让主线程结束之后,不要直接结束整个进程呢?那就是让主线程等待子线程结束,就是下面这个函数。pthread_join() ,修改代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* fun(void*arg)
{
for(int i=0;i<5;i++)
{
printf("fun hello\n");
sleep(1);
}
pthread_exit("fun over"); //注意:返回给主线程的数据不能传递临时变量,它会随着函数结束而销毁
}
int main()
{
pthread_t id;
pthread_create(&id,NULL,fun,NULL);//creat函数成功后会把线程id,传给id的。
for(int i=0;i<2;i++)
{
printf("main hello\n");
sleep(1);
}
char*s=NULL;
pthread_join(id,(void**)&s);//主线程会阻塞住,等待子线程结束,并接受子线程返回的信息
printf("main s=%d\n",s);
}
}
pthread_join() 它会等待子线程的结束,第一个参数是线程id,第二个参数是二级指针,一级指针的地址,改变指针的指向,使子线程能给主线程返回信息,让指针指向子线程返回的值。
pthread_exit(); 它的作用是退出子线程,并且会返回给主线程一个信息,主线程通过指针s指向它,然后再打印出来! 千万不能写exit() ,这个函数会直接结束整个进程!!
运行结果如下:
执行过程如下:主线程打印2次,然后被阻塞住,等待子线程打印5次,退出子线程,将返回信息给主线程,然后主线程打印子线程返回给它的信息。
2.1.2 退出当前线程函数
void pthread_exit(void *retval);
参数:
retval
:一个指向返回值的指针,线程的退出状态可以通过它来传递。说明:
- 当一个线程调用
pthread_exit
时,该线程会终止执行,其退出状态会被传递给其他线程(例如通过pthread_join
函数)。- 调用
pthread_exit
不会终止整个进程,只会终止调用它的线程。- 如果主线程调用了
pthread_exit
,主线程会终止,但其他子线程会继续运行,直到它们自己终止或者整个进程终止。
2.1.3 等待子线程结束函数
int pthread_join(pthread_t thread, void **retval);
参数:
thread
:要等待终止的线程的线程ID。retval
:一个指向指针的指针,用于存储子线程的退出状态。如果不需要获取退出状态,可以传递NULL
。返回值:
- 成功时返回
0
。- 失败时返回一个错误码。
2.2 线程并发运行
2.2.1 示例代码1
主线程创建5个子线程,让它们打印它们是第几个被创建的(通过传地址,然后子线程解引用拿到对应的i )。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void*fun(void*arg)
{
int index=*(int*)arg; //通过解引用拿到传递过来的值
for(int i=0;i<3;i++)
{
printf("index=%d\n",index);
sleep(1);
}
}
int main()
{
pthread id[5];
int i=0;
for(;i<5;i++) //创建5个子线程
{
pthread_creat(&id[i],NULL,(void*)&i); //把创建的顺序作为参数传递给子线程函数
}
for(i=0;i<5;i++) //等待5个子线程结束
{
pthread_join(id[i],NULL);
}
exit(0);
}
按照我们对上述程序的理解:主线程创建5个子线程,把对应的创建顺序(0 、1 、2 、3 、4)传递给子线程,然后5个子线程每一轮打印自己的创建顺序,一轮打印完,休眠1秒,然后继续打印下一轮,一共打印3轮。即结果应该是:0 1 2 3 4 0 1 2 3 4 0 1 2 3 4
实际运行结果如下:
分析:首先,主线程向内核申请5个子线程(这个时间非常快),然后内核依次一个一个的创建出5个子线程,等5个子线程全部创建完毕后,然后CPU开始进行调度这5个子线程执行,但是5个子线程的执行顺序与我们的创建的顺序未必是相同的,这是由调度算法决定的(处理器的个数是固定的,第一个创建的子线程未必就是第一个运行的),子线程在启动运行后,只有执行到 int index=(int)arg;这一行了,才去获取i的值,如果有多个线程同时启动(并且是多核处理器,多个线程并行运行),它们拿到的都是同一个值 i, 因此,他们几个都会打印出相同的值;如果出现全是0,代表:当主线程创建出5个子线程后(时间非常快),然后置0了,并且在里面阻塞了至少3秒,这时候5个子线程去获取i的值,打印出来的都是0。子线程获取的值会随着主线程的改变而变化!
2.2.2 示例代码2
定义一个全局变量, 主线程创建5个子线程,这5个子线程每个子线程对这个全局变量进行自增操作1000次。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
int g_val = 1;
void* thread_fun(void *arg)
{
int i = 0;
for( ;i < 1000; i++ )
{
printf("g_val=%d\n", g_val++);
}
}
int main()
{
pthread_t id[5];
int i = 0;
for( ; i < 5; i++ )
{
pthread_create(&id[i], NULL, thread_fun, NULL);
}
for( i = 0;i < 5; i++ )
{
pthread_join(id[i], NULL);
}
exit(0);
}
正常理解:打印的最后一个值为:5000.
运行结果如下:
多个线程并行运行,拿到的是同一个i,因此它们都对i 自增一次, 并且是同一个值,导致最后加不到5000.
深入理解:i++
我们运行的是可执行程序,它是由一条条的二进制指令构成,然后进行编译、汇编、链接操作转换成机器码,当运行i++的操作时,首先计算i的地址,然后将数据从内存加载到CPU,存放到相关的寄存器,经过ALU计算,再写入到相关的寄存器,然后将寄存器中的值写回到内存,中间经历了好几个步骤,因此它不是原子操作!
因此,当一个线程对i++操作的过程中,还没有进行++操作,此时,又有另外一个进程来读取i,然后进行自增操作,那么这两个线程相当于就只进行了一次++操作,因此,总数达不到我们想要的结果!
有个全局变量i值为1,如果有两个线程都要i++,如果有一个cpu,那就只能一个线程i++,另外一个线程i++;如果有两个cpu,那就可能两个线程同时对相同的i值进行++,那么加完的值就相同,本来加五次i值为10,有两个cpu呢,结果就可能小于等于10了,有可能俩个线程同时++。
如何解决呢?
方式1:切换为单核处理器,那么同一时刻就只有一个线程进行自增,这样就能保证能够加到5000.
方式2:一个线程在进行自增操作的时候,另一个线程先等着,不要进行任何操作,等这个线程执行完,再进行自增操作,这就是下面要讲的线程同步。
三、线程同步
3.1 线程同步的概念
线程同步指的是当一个线程在对某个临界资源进行操作时,其他线程都不可以对这个资源进行操作,直到该线程完成操作,其他线程才能操作,也就是协同步调,让线程按预定的先后次序进行运行。线程同步的方法有四种:互斥锁、信号量、条件变量、读写锁。(面试题)
锁(Locks): 锁是一个机制,用于控制对共享资源的访问。常见的锁包括互斥锁(Mutex)和读写锁(Read-Write Lock)。只有获得锁的线程才能进入临界区,其他线程必须等待锁被释放。
互斥锁(Mutex): 互斥锁是最常见的同步机制,它确保同一时间只有一个线程可以访问共享资源。当一个线程获得互斥锁后,其他线程必须等待直到该锁被释放。
信号量(Semaphore): 信号量是一个计数器,用于控制对共享资源的访问。信号量允许多个线程同时访问一定数量的共享资源。信号量分为计数信号量和二进制信号量(类似于互斥锁)。
条件变量(Condition Variable): 条件变量允许线程在某个条件不满足时释放锁并进入等待状态。当条件满足时,其他线程可以通知等待的线程继续执行。条件变量通常与互斥锁结合使用。
读写锁(Read-Write Lock): 读写锁允许多个线程同时读取共享资源,但在写操作时必须独占锁。读写锁分为读锁和写锁,读锁可以被多个线程同时持有,而写锁只能被一个线程持有。
3.2 信号量
利用信号量进行线程的同步控制比起利用信号量进行进程间通信简单多了,封装好的函数接口,直接引入头文件,调用函数即可。
需要引入的头文件 #include <semaphore.h>
3.2.1 函数接口介绍
3.2.1.1 信号量初始化函数
int sem_init(sem_t *sem, int pshared, unsigned int value);
初始化信号量。
参数说明:
sem
: 指向信号量对象的指针。pshared
: 指定信号量是否在进程间共享。若为0,表示线程间共享;若非0,表示进程间共享。value
: 信号量的初始值。
3.2.1.2 等待信号量(P操作)
int sem_wait(sem_t *sem);
等待信号量,如果信号量的值大于0,则减1并立即返回;如果信号量的值为0,则阻塞直到信号量的值大于0。
sem
: 指向信号量对象的指针。
3.2.1.3 释放信号量(V操作)
int sem_post(sem_t *sem);
释放信号量,即增加信号量的值。如果有线程正在等待信号量,它将被唤醒。
sem
: 指向信号量对象的指针.
3.2.1.4 销毁信号量
int sem_destroy(sem_t *sem);
销毁信号量。
sem
: 指向信号量对象的指针.
3.2.2 使用示例
每个线程进行自增之前,先进行P操作,获取信号量,使用该进程资源,信号量减为0,此时其他线程P操作不会成功,就会阻塞住,等使用完,再进行V操作,释放信号量,信号量加1.为其他线程使用做准备。这样保证同一时刻,只有一个线程访问这块进程资源,进行自增操作!
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
sem_t sem; //定义一个信号量
int g_val = 1;
void* thread_fun(void *arg)
{
int i = 0;
for( ;i < 1000; i++ )
{
sem_wait(&sem); //P操作,获取信号量
printf("g_val=%d\n", g_val++); //临界区
sem_post(&sem); //V操作,释放信号量
}
}
int main()
{
pthread_t id[5];
sem_init(&sem,0,1); //对信号量进行初始化
int i = 0;
for( ; i < 5; i++ )
{
pthread_create(&id[i], NULL, thread_fun, NULL);
}
for( i = 0;i < 5; i++ )
{
pthread_join(id[i], NULL);
}
destroy(&sem); //销毁信号量
exit(0);
}
3.2.3 面试题
题目描述:编写一个程序,开启三个线程,这三个线程按照顺序依次打印ABC,每个字母打印5次后结束,最后结果如 ABCABCABC… 依次递推.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
//定义3个信号量
sem_t sema;
sem_t semb;
sem_t semc;
void*funa(void*arg)
{
for(int i=0;i<5;i++)
{
sem_wait(&sema); //ps1
printf("A");
fflush(stdout);
sem_post(&semb); //vs2
sleep(1);
}
}
void*funb(void*arg)
{
for(int i=0;i<5;i++)
{
sem_wait(&semb); //ps2
printf("B");
fflush(stdout);
sem_post("c"); //vs3
sleep(1);
}
void*func(void*arg)
{
for(int i=0;i<5;i++)
{
sem_wait(&semc); //ps3
printf("A");
fflush(stdout);
sem_post(&sema); //vs1
sleep(1);
}
int main
{
pthread id1,id2,id3;
sem_init(&sema,0,1);
sem_init(&semb,0,0);
sem_init(&semc,0,0);
pthread_create(&id1,NULL,funa,NULL);
pthread_create(&id2,NULL,funb,NULL);
pthread_create(&id3,NULL,func,NULL);
pthread_join(id1,NULL);
pthread_join(id2,NULL);
pthread_join(id3,NULL);
sem_destroy(&sema);
sem_destroy(&semb);
sem_destroy(&semb);
exit(0);
}
3.3 互斥锁
利用互斥锁进行线程的同步控制其实和利用信号量进行线程同步思想一样,加锁就相当于P操作,解锁就相当于V操作,试想生活中的试衣间,我们在使用试衣间的时候,进去之后会上锁,这样外面的人就无法使用,使用完后,出来,然后打开锁,这样外面的人就可以使用了,这样就可以保证同一时刻,只有一个线程在使用这个资源,如果资源被加锁,其他的线程再访问这块资源,就会被阻塞住!!其实,互斥锁就是信号量值为1的信号量。同样,它也有直接封装好的函数接口,直接引入头文件,调用函数即可。
需要引入的头文件 #include <pthread.h>
3.3.1 函数接口介绍
3.3.1.1 互斥锁初始化函数
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
- 功能:初始化互斥锁。
- 参数:
mutex
:指向要初始化的互斥锁的指针。attr
:指向互斥锁属性的指针,通常设置为NULL以使用默认属性。- 返回值:成功返回0,失败返回错误码。
3.3.1.2 加互斥锁函数
int pthread_mutex_lock(pthread_mutex_t *mutex);
- 功能:锁定互斥锁,如果互斥锁已经被其他线程锁定,则阻塞当前线程。
- 参数:
mutex
:指向要锁定的互斥锁的指针。- 返回值:成功返回0,失败返回错误码。
3.3.1.3 解互斥锁函数
int pthread_mutex_unlock(pthread_mutex_t *mutex);
- 功能:解锁互斥锁。
- 参数:
mutex
:指向要解锁的互斥锁的指针。- 返回值:成功返回0,失败返回错误码。
3.3.1.4 销毁互斥锁函数
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 功能:销毁互斥锁。
- 参数:
mutex
:指向要销毁的互斥锁的指针。- 返回值:成功返回0,失败返回错误码。
3.3.2 使用示例
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
#include <semaphore.h>
pthread_mutex_t mutex; //定义一个锁变量
int g_val = 1;
void* thread_fun(void *arg)
{
int i = 0;
for( ;i < 1000; i++ )
{
pthread_mutex_lock(&mutex); // 加互斥锁
printf("g_val=%d\n", g_val++); //临界区
pthread_mutex_unlock(&mutex); // 解互斥锁
}
}
int main()
{
pthread_t id[5];
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
int i = 0;
for( ; i < 5; i++ )
{
pthread_create(&id[i], NULL, thread_fun, NULL);
}
for( i = 0;i < 5; i++ )
{
pthread_join(id[i], NULL);
}
pthread_mutex_destroy(&mutex); // 销毁互斥锁
exit(0);
}
3.4 条件变量
在Linux系统中,利用条件变量(condition variables)可以实现线程间的同步。条件变量通常与互斥锁(mutex)一起使用,以确保线程安全。条件变量允许一个或多个线程在特定条件不满足时阻塞(等待),直到条件满足时被唤醒。同样,它也有直接封装好的函数接口,直接引入头文件,调用函数即可。
线程在条件变量队列上等待被唤醒,如何唤醒是由我们来决定的,在条件变量队列上我们可以唤醒一个线程,也可以唤醒全部线程。
需要引入的头文件 #include <pthread.h>
3.4.1 函数接口介绍
3.4.1.1 初始化条件变量
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
cond
: 指向条件变量的指针。attr
: 指向条件变量属性对象的指针,通常传递NULL使用默认属性。
3.4.1.2 唤醒一个等待线程
int pthread_cond_signal(pthread_cond_t *cond);
cond
: 指向条件变量的指针。该函数会唤醒一个等待在此条件变量(消息队列)上的线程。如果有多个线程等待,则唤醒其中一个线程。
3.4.1.3 唤醒所有等待线程
int pthread_cond_broadcast(pthread_cond_t *cond);
cond
: 指向条件变量的指针。该函数会唤醒所有等待在此条件变量(消息队列)上的线程。
3.4.1.4 等待条件变量
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
cond
: 指向条件变量的指针。mutex
: 指向已锁定的互斥锁的指针。在调用此函数时,互斥锁会被解锁,然后线程进入等待状态。一旦线程被唤醒,互斥锁会被重新锁定。
3.4.1.5 销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
cond
: 指向条件变量的指针。
3.4.2 使用示例
创建两个子线程,将其放在条件变量队列中,当我们从键盘输入数据,唤醒一个线程来打印数据,也可以唤醒这两个线程打印。
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
pthread_mutex_t mutex; //定义互斥锁
pthread_cond_t cond; //定义条件变量队列
void*funa(void*arg)
{
char*s=(char*)arg;
while(1)
{
//等待条件变量可用
//条件变量
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//添加到条件变量的等待队列--阻塞--唤醒->解除阻塞
pthread_mutex_unlock(&mutex);
if(strncmp(s,"end",3)==0)
{
break;
}
printf("funa s=%s\n",s);
}
}
void*funb(void*)
{
char*s=(char*)arg;
while(1)
{
//等待条件变量可用
//条件变量
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond,&mutex);//添加到条件变量的等待队列--阻塞--唤醒->解除阻塞
pthread_mutex_unlock(&mutex);
if(strncmp(s,"end",3)==0)
{
break;
}
printf("funa s=%s\n",s);
}
}
int main()
{
pthread_mutex_init(&mutex,NULL); //初始化互斥锁
pthread_cond_init(&cond,NULL); //初始化条件变量队列
pthread_t id1,id2;
pthread_create(&id1,NULL,funa,buff); //创建两个线程
pthread_create(&id2,NULL,funb,buff);
while(1)
{
fgets(buff,128,stdin);
if(strncmp(buff,"end",3)==0)
{
pthread_cond_broadcast(&cond); //唤醒所有线程,运行完退出
break;
}
else
{
pthread_cond_signal(&cond); //唤醒单个线程
}
}
pthread_join(id1,NULL);
pthread_join(id2,NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
exit(0);
}
为什么要加锁?
因为要唤醒时,有的线程要加入队列,有的要出队列,这些线程不确定,我们到底算不算他们。这一步进行了两次对锁的操作,先解锁;如果被唤醒时,要先加上锁,出队列,保证同一时刻只有我一个人在出队列,一个线程操作队列。所以出队列,之后还要再解一次锁。锁变量传到方法内部,内部会进行解锁,再加锁操作。只有在队列中有线程等待唤醒才能把线程唤醒,要是对列中没有线程等待,白唤醒,没人理。
3.5 读写锁
读写锁(也称为共享-独占锁)是一种同步机制,它允许多个线程同时读取共享资源,但在写入共享资源时,只有一个线程能够进行操作,且在写入期间不允许其他线程读取。读写锁针对于,俩个人一起读文件可以,但加了读锁了,就不能进行写入;加了写锁了,就不能读了;加了写锁,另一个人在想写,也是不能通过 这种机制适用于读多写少的场景,可以提高并发性能。封装好的函数接口,直接引入头文件,调用函数即可。
需要引入的头文件 #include <pthread.h>
3.5.1 函数接口介绍
3.5.1.1 读写锁初始化
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
- rwlock:指向读写锁变量的指针。
- attr:指向读写锁属性对象的指针,可以为NULL,表示使用默认属性。
- 返回值:成功返回0,失败返回错误码。
3.5.1.2 获取读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
- rwlock:指向读写锁变量的指针。
- 返回值:成功返回0,失败返回错误码。
3.5.1.3 获取写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
- rwlock:指向读写锁变量的指针。
- 返回值:成功返回0,失败返回错误码。
3.5.1.4 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
- rwlock:指向读写锁变量的指针。
- 返回值:成功返回0,失败返回错误码。
3.5.1.5 释放锁(读锁或写锁)
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
- rwlock:指向读写锁变量的指针。
- 返回值:成功返回0,失败返回错误码。
3.5.2 使用示例
主线程创建三个线程,两个线程用来读,另外一个线程用来写,我们可以知道:两个读线程可以同时运行,但是读和写不能交错运行。
四、线程安全
4.1 概念
线程安全即就是在多线程运行的时候,不论线程的调度顺序怎样,最终的结果都是 一样的、正确的。那么就说这些线程是安全的。
4.2 多线程环境如何保证线程安全
要保证线程安全需要做到:
- 对线程同步,保证同一时刻只有一个线程访问临界资源。
- 在多线程中使用线程安全的函数(可重入函数),所谓线程安全的函数指的是:如果一个 函数能被多个线程同时调用且不发生竟态条件,则我们程它是线程安全的。
函数以前使用不会出现问题,但在多线程环境下,出现问题,有可能是函数本身导致的,由此我们要使用此类函数的线程安全版本。多线程环境中,库函数使用线程安全版本。
4.3 举例理解
两个进程分别调用strtok()对字符串进行分割,并发运行。
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* PthreadFun(void *arg)
{
char buff[] = "a b c d e f g h i";
char *p = strtok(buff, " ");
while(p != NULL)
{
printf("fun:: %c\n", *p);
p = strtok(NULL, " "); //这里为NULL,就会去寻找ptr指针指向的位置
sleep(1);
}
}
int main()
{
pthread_t id;
int res = pthread_create(&id, NULL, PthreadFun, NULL);
assert(res == 0);
char buff[] = "1 2 3 4 5 6 7 8 9";
char *p = strtok(buff, " ");
while(p != NULL)
{
printf("main:: %c\n", *p);
p = strtok(NULL, " ");
sleep(1);
}
}
不使用线程安全版本的strtok函数,打出结果是主线程和子线程分割同一个字符串。因为strtok里有静态指针,它记录着分割字符串的位置,再次使用strtok时传入新的字符串地址,就会更改原来静态指针记录的位置为新字符串,会导致俩个线程分割同一个字符串。修改代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>
void* PthreadFun(void *arg)
{
char buff[] = "a b c d e f g h i";
char *q = NULL;
char *p = strtok_r(buff, " ", &q);
while(p != NULL)
{
printf("fun:: %c\n", *p);
p = strtok_r(NULL, " ", &q);
sleep(1);
}
}
int main()
{
pthread_t id;
int res = pthread_create(&id, NULL, PthreadFun, NULL);
assert(res == 0);
char buff[] = "1 2 3 4 5 6 7 8 9";
char *q = NULL;
char *p = strtok_r(buff, " ", &q);
while(p != NULL)
{
printf("main:: %c\n", *p);
p = strtok_r(NULL, " ", &q);
sleep(1);
}
}
五、线程与fork
5.0 多线程的PID
上述代码子线程和父线程打印进程的PID,我们知道线程只是进程中的一条执行路径,不管是主线程还是子线程它们都属于同一个进程,因此,它们两个打印的结果应该相同。
5.1 多线程中某个线程调用 fork(),子进程会有和父进程相同数量的线程吗?
5.1.1 主线程调用fork()产生子进程
运行结果如下:
5.1.2 子线程调用fork()产生子进程
运行结果如下:
总结:多线程中某个线程调用 fork(),子进程会有和父进程线程数量不相同,并且在哪个线程调用fork(),则子进程就只会执行fork()所在的那个线程!!
5.2 父进程被加锁的互斥锁 fork 后在子进程中是否已经加锁?
结果如下:
程序中父进程经过延时确保其子线程处于加锁状态,然后才进行进程的复制,子进程未加锁成功!
总结:
- 在父进程中定义的锁,产生子进程后,子进程也会有拷贝的锁,但是父子进程各自使用各自的锁;
- 子进程锁的状态取决于fork()复制的那一刻锁的状态,因为锁在父进程中的状态是变化的。 ———————如何解决呢?
解决办法:在复制子进程之前先进行一次加锁操作,如果能成功,说明锁的状态是解锁状态,直接复制,然后在子进程解锁;如果失败,说明锁的状态是加锁状态,阻塞延时等待子线程释放锁,然后在复制子进程。
结果如下:
六、生产者消费者模型(面试题)
6.1 生产者消费者问题概述
生产者/消费者问题,也被称作有限缓冲问题。可以描述为:两个或者更多的线程共享同一个缓冲 区,其中一个或多个线程作为“生产者”会不断地向缓冲区中添加数据,另一个或者多个线程作为“消费者” 从缓冲区中取走数据。
生产者/消费者模型关注的是以下几点:
- 生产者和消费者必须互斥的使用缓冲区
- 缓冲区空时,消费者不能读取数据
- 缓冲区满时,生产者不能添加数据
6.2 生产者消费者模型优点
- 解耦:因为多了一个缓冲区,所以生产者和消费者并不直接相互调用,这样生产者和消费者的代码 发生变化,都不会对对方产生影响。这样其实就是把生产者和消费者之间的强耦合解开,变成了生 产者和缓冲区,消费者和缓冲区之间的弱耦合
- 支持并发:如果消费者直接从生产者拿数据,则消费者需要等待生产者生产数据,同样生产者需要 等待消费者消费数据。而有了生产者/消费者模型,生产者和消费者可以是两个独立的并发主体。 生产者把制造出来的数据添加到缓冲区,就可以再去生产下一个数据了。而消费者也是一样的,从 缓冲区中读取数据,不需要等待生产者。这样,生产者和消费者就可以并发的执行。
- 支持忙闲不均:如果消费者直接从生产者这里拿数据,而生产者生产数据很慢,消费者消费数据很 快,或者生产者生产数据很多,消费者消费数据很慢。都会造成占用CPU的时间片白白浪费。生产 者/消费者模型中,生产者只需要将生产的数据添加到缓冲区,缓冲区满了就不生产了。消费者从 缓冲区中读取数据,缓冲区空了就不消费了,使得生产者/消费者的处理能力达到一个动态的平 衡。
6.3 生产者消费者模型实现
假定缓冲池中有N个缓冲区,一个缓冲区只能存储一个int类型的数据。定义互斥锁mutex实现对缓冲区的互斥访问;计数信号量dempty用来表示空闲缓冲区的数量,其初值为N;计数信号量dfull用来表示有数据的缓冲区的数量,其初值为0 。
- 生产者与消费者互斥,生产者与生产者之间互斥, 消费者与消费者之间互斥。
- 定义两个信号量,生产者的信号量大小跟缓冲区大小一样,消费者的信号量初始值为0
- 互斥锁应该加在p操作的后面,因为如果缓冲区满了,先拿到锁,不能写入数据,别人也拿不到锁,此时就成为死锁了,生产者与消费者数量可以不对等,可以为多个,他们俩的数量没有关系,消费者谁抢到锁谁就消费;
代码实现:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <assert.h>
#include <pthread.h>
#include <semaphore.h>
#include <time.h>
#define BUFF_MAX 30
#define SC_NUM 2
#define XF_NUM 3
int in = 0;
int out = 0;
sem_t sem_empty;
sem_t sem_full;
pthread_mutex_t mutex;
int buff[BUFF_MAX] = {0};
void * sc_thread(void* arg)
{
int index = (int)arg;
while( 1 )
{
sem_wait(&sem_empty); //ps1
pthread_mutex_lock(&mutex); //加锁
buff[in] = rand()%100;
printf("生产者%d 产生数据%d,in=%d\n",index,buff[in],in);
in = (in + 1) % BUFF_MAX; //更新下标
pthread_mutex_unlock(&mutex); //unlock
sem_post(&sem_full); //vs2
int n = rand() % 10;
sleep(n);
}
}
void * xf_thread(void* arg)
{
int index = (int)arg;
while(1)
{
sem_wait(&sem_full); //ps2
pthread_mutex_lock(&mutex); //加锁
printf("消费者%d 消费数据%d, out=%d\n",index,buff[out],out);
out = (out+1) % BUFF_MAX; //更新下标
pthread_mutex_unlock(&mutex); //解锁
sem_post(&sem_empty); //vs1
int n = rand() % 10;
sleep(n);
}
}
int main()
{
pthread_mutex_init(&mutex,NULL);
sem_init(&sem_empty,0,BUFF_MAX);
sem_init(&sem_full,0,0);
srand((int)time(NULL));
pthread_t sc_id[SC_NUM];
pthread_t xf_id[XF_NUM];
int i = 0;
for( ; i < SC_NUM; i++ )
{
pthread_create(&sc_id[i],NULL,sc_thread,(void*)i);
}
for( i = 0; i < XF_NUM; i++ )
{
pthread_create(&xf_id[i],NULL,xf_thread,(void*)i);
}
for( i = 0; i < SC_NUM; i++ )
{
pthread_join(sc_id[i],NULL);
}
for( i = 0; i < XF_NUM; i++ )
{
pthread_join(xf_id[i],NULL);
}
sem_destroy(&sem_empty);
sem_destroy(&sem_full);
pthread_mutex_destroy(&mutex);
exit(0);
}
七、面试题总结
- 进程与线程的区别
- 线程同步的方法有哪些:信号量 互斥锁 读写锁 条件变量
- 线程安全:同步,使用线程安全的函数(可重入函数)
- 生产者消费者模型 ,给场景问,如何实现同步
至此,线程已经讲解完毕!篇幅较长,慢慢消化,以上就是全部内容!请务必掌握,创作不易,欢迎大家点赞加关注评论,您的支持是我前进最大的动力!下期再见!