一、基本概念
1.1 应用程序
以 Windows 为例,一个拓展名为 .exe 的文件就是一个应用程序,应用程序是能够双击运行的。
1.2 进程
应用程序运行起来就创建了一个进程,即进程就是运行起来的应用程序;如电脑上运行的 Edge、Typora、PotPlayer 等。
进程的特点:
- 一个进程至少包含一个线程(主线程,main)。
- 可以包含多个线程(主线程+若干子线程)。
- 所有线程共享进程的资源。
1.3 线程
1.3.1 线程概念
我们知道,一个进程指的是一个正在执行的应用程序;而线程则是执行进程中的某个具体任务,比如一段程序、一个函数等。
进程想要执行任务就需要依赖线程;换句话说,线程就是进程中的最小执行单位,并且一个进程中至少有一个线程(主线程)。
1.3.2 主线程
- 每个进程都有一个主线程,这个主线程是唯一的。
- 当你运行了一个应用程序产生了一个进程后,这个主线程就随着这个进程默默地启动起来了。
- 主线程的生命周期与进程的生命周期相同,它俩同时存在、同时结束,是唇齿相依的关系。
- 一个进程只能有一个主线程,就像一个项目中只能有一个 main 函数一样。
1.4 进程和线程的关系
线程和进程之间的关系,类似于工厂和工人之间的关系:
- 进程好比是工厂,线程就如同工厂中的工人。
- 一个工厂可以容纳多个工人,工厂负责为所有工人提供必要的资源(电力、产品原料、食堂、厕所等),所有工人共享这些资源。
- 每个工人负责完成一项具体的任务,他们相互配合,共同保证整个工厂的平稳运行。
进程仅负责为各个线程提供所需的资源,真正执行任务的是线程,而不是进程。
二、多线程概念
提到多线程这里要说两个概念,就是串行和并行,搞清楚这个,我们才能更好地理解多线程。
2.1 串行和并行
2.1.1 串行
所谓串行,其实是相对于单条线程来执行多个任务来说的,我们就拿下载文件来举个例子:当我们下载多个文件时,在串行中它是按照一定的顺序去进行下载的,也就是说,必须等下载完 A 之后才能开始下载 B;它们在时间上是不可能发生重叠的。
2.1.2 并行
下载多个文件,多个文件同时进行下载;这里是严格意义上的,在同一时刻发生的,并行在时间上是重叠的。
2.2 多线程
了解了串行和并行这两个概念之后,我们再来说说什么是多线程。举个例子,我们打开腾讯管家,腾讯管家本身就是一个应用程序,也就是说它就是一个进程,它里面有很多的功能,我们可以看下图,能查杀病毒、清理垃圾、电脑加速等众多功能:
按照单线程来说,无论你想要清理垃圾、还是要病毒查杀,那么你必须先做完其中的一件事,才能做下一件事,这里面是有一个执行顺序的。如果是多线程的话,我们其实在清理垃圾的时候,还可以进行查杀病毒、电脑加速等等其他的操作,这个是严格意义上的同一时刻发生的,没有执行上的先后顺序。
所谓多线程,即一个进程中拥有多个线程(≥2,主线程+若干子线程),线程之间相互协作、共同执行一个应用程序。
三、多线程编程
我们通常将以「多线程」方式编写的程序称为「多线程程序」,将编写多线程程序的过程称为「多线程编程」,将拥有多个线程的进程称为「多线程进程」。
PS:以下代码是在 Linux 下运行的。
3.1 pthread_t
定义:typedef unsigned long int pthread_t;
。
功能:用于声明线程ID,是一个线程标识符。
3.2 pthread_create()
3.2.1 函数介绍
函数原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
头 文 件:#include <pthread.h>
功能介绍:用来创建一个线程
参数介绍:
- 第一个参数为指向线程标识符的指针
- 第二个参数用来设置线程属性,一般置为 NULL,表示使用默认属性
- 第三个参数是线程执行的函数,返回值为 void *
- 最后一个参数是线程执行函数的参数
返 回 值:
- 当创建线程成功时,函数返回0
- 若不为 0 则说明创建线程失败,常见的错误返回代码为 EAGAIN 和 EINVAL:
- 前者表示系统限制创建新的线程,例如线程数目过多了
- 后者表示第二个参数代表的线程属性值非法
3.2.2 牛刀小试
下面我们通过代码来深入理解如何创建一个子线程。
#include <stdio.h>
#include <pthread.h>
#include <errno.h>
/* 线程执行函数 */
void *func(void *arg)
{
int i;
for (i = 1; i <= 10; i++) //该函数执行动作:打印 10 次 func
{
printf("func[%d]\n", i);
}
return NULL;
}
int main()
{
printf("主线程开始运行\n");
pthread_t th; //定义一个线程标识符
/* 使用默认属性创建线程,该线程执行 func 函数 */
if (0 != pthread_create(&th, NULL, func, NULL))
{
/* 输出错误日志并打印相应的错误码 */
printf("fail, errno[%d, %s], \n", errno, strerror(errno));
}
/* 阻塞主线程,不然,主线程马上结束,从而使创建的线程没有机会开始执行就结束了 */
sleep(2);
printf("主线程结束运行\n");
return 0;
}
注意:编写 Linux 下的多线程程序时,需要使用头文件pthread.h,连接时需要使用静态库 libpthread.a。
运行结果如下:
上述代码中,我们通过在 main 中添加 sleep 来阻塞主线程,以此保证子线程可以正常运行并终止;但通过 sleep 的方式阻塞主线程多少会影响程序效率,所以我们需要换一种方式来阻塞主线程。
3.3 pthread_join()
3.3.1 小栗子
在讲解 pthread_join 前,我们先来通过一个小栗子初步体验一下为何需要 pthread_join。
场景 1
在简单的程序中一般只需要一个线程就可以搞定,也就是主线程:
int main()
{
printf("主线程开始运行\n");
return 0;
}
现在假设我要做一个比较耗时的工作,从一个服务器下载一个视频并进行处理,那么我的代码会变成:
int main()
{
printf("主线程开始运行\n");
download(); // 下载视频到本地
process(); // 视频处理
return 0;
}
场景 2
如果我需要下载两个视频素材,一起在本地进行处理,也很简单:
int main()
{
printf("主线程开始运行\n");
download1(); //下载视频 1
download2(); //下载视频 2
process(); //处理视频 1、2
return 0;
}
本身这么做完全没有问题,可是就是有点浪费时间,如果两个视频能够同时下载就好了,这时候线程就派上了用场。
#include <stdio.h>
#include <pthread.h>
void *download1(void *arg)
{
puts("子线程开始下载第一个视频...");
sleep(6); // 耗时 6s
puts("第一个视频下载完成");
}
void *download2(void *arg)
{
puts("主线程开始下载第二个视频...");
sleep(10); // 耗时 10s
puts("第二个视频下载完成");
return NULL;
}
void process()
{
puts("开始处理两个视频...");
sleep(3); // 耗时 3s
puts("处理完成");
}
int main()
{
printf("主线程开始运行\n");
pthread_t th;
pthread_create(&th, NULL, download1, NULL); // 子线程下载视频 1
download2(NULL); // 主线程下载视频 2
process();//处理视频1、2
return 0;
}
主线程叫来了 th 这个线程去下载「视频 1」,自己去下载「视频 2」;减轻了自己的工作量也缩短了时间。
通过download
函数的对比,可以发现,两个视频同时下载肯定是「视频 1」先下载完,这样在主线程下载完「视频 2」的时候,「视频 1」已经准备好了,后面就可以一起进行处理,这没什么问题。
但是万一「视频 1」的下载时间比「视频 2」的时间长呢(比如下载「视频 2」仅需要耗费 3s 的时间)?当「视频 2」下载完成了,但此时子线程 th 还没干完活,本地还没有「视频 1」,那么接下来处理的时候肯定会有问题,或者说接下来不能直接进行处理,要等 th 干完活后,主线程中的process
函数才能去处理这两个视频。
在这种场景下就用到了pthread_join()
这个函数。
3.3.2 pthread_join介绍
函数原型:int pthread_join(pthread_t thread, void **retval);
头 文 件:#include <pthread.h>
功能介绍:用来等待一个线程的结束
参数介绍:
- 第一个参数为被等待的线程标识符
- 第二个参数为一个用户定义的指针,它可以用来保存被等待线程的返回值
返 回 值:
- On success, pthread_join() returns 0
- On error, it returns an error number
这个函数是一个线程阻塞的函数,调用它的函数将一直等待到被等待的线程结束为止,当函数返回时,被等待线程的资源被收回。
3.3.3 调用 pthread_join
下面,我们通过 pthread_join 修改一下「场景 2」的代码:
void *download1(void *arg)
{
puts("子线程开始下载第一个视频...");
sleep(6); // 耗时 6s
puts("第一个视频下载完成");
}
void *download2(void *arg)
{
puts("主线程开始下载第二个视频...");
sleep(3); // 耗时 3s
puts("第二个视频下载完成");
return NULL;
}
int main()
{
printf("主线程开始运行\n");
pthread_t th;
pthread_create(&th, NULL, download1, NULL); // 子线程下载视频 1
download2(NULL); // 主线程下载视频 2
pthread_join(th, NULL); // 阻塞主线程,直到「视频 1」下载完成
process();//处理视频1、2
return 0;
}
现在下载「视频 1」需要 6s,下载「视频 2」需要 3s;当「视频 2」下载完成后要等待「视频 1」下载完成方可一起进行处理,为了实现这个目的,我们在第 24 行加入了pthread_join()
。
在这个场景下,我们明确两个事情:
Q1:谁调用了pthread_join
函数?
- th 这个线程对象调用了
pthread_join
函数,因此必须等待 th 的下载任务结束了,pthread_join()
才能返回。
Q2:在哪个线程环境下调用了pthread_join
函数?
- th 是在主线程的环境下调用了
pthread_join
函数的,因此主线程要等待 th 的工作做完,否则主线程将一直处于阻塞状态。
这里不要搞混的是子线程 th 真正做的任务(下载「视频 1」)是在另一个线程中做的;但是 th 调用
pthread_join
函数的动作是在主线程环境下做的。
3.3.4 获取线程任务的返回值
子线程执行的函数在结束后可能会有返回值:
#define STRING_LEN_24 24
/* 线程执行函数 */
void *func(void *arg)
{
int i;
for (i = 1; i <= 10; i++) //该函数执行动作:打印 10 次 func
{
printf("func[%d]\n", i);
}
char *buf = (char *)malloc(STRING_LEN_24);
strncpy(buf, "The child thread ends", STRING_LEN_24 - 1);
return buf;
}
这种情况下,该如何处理呢?还记得pthread_join()
函数的第二个参数吗?这个参数就是用来保存线程函数的返回值的:
int main()
{
printf("主线程开始运行\n");
pthread_t th;
if (0 != pthread_create(&th, NULL, func, NULL))
{
printf("fail, errno[%d, %s], \n", errno, strerror(errno));
}
char *buf;
pthread_join(th, (void **)&buf);
printf("子线程返回值[%s]\n", buf);
printf("主线程结束运行\n");
return 0;
}
运行结果:
3.3.5 及时释放资源
引入一个新的概念:线程分离(detach)和非分离(join)状态。线程的分离状态决定一个线程以什么样的方式来终止自己。
在默认情况下线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()
函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。也就是说,通过默认属性创建的线程必须要通过调用pthread_join()
函数来释放线程资源,换句话说,非分离状态的线程一定要调用pthread_join()
函数。
对于非分离状态的线程,如果不及时调用pthread_join()
函数,则会导致资源泄露。下面就通过创建大量非分离状态的线程,但不调用pthread_join()
函数来观察会出现什么情况。
#include <stdio.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>
/* 线程函数,不作任何操作 */
void *func(void *arg)
{
return NULL;
}
int main()
{
int i;
for (i = 1; ; i++)
{
pthread_t th;
if (0 != pthread_create(&th, NULL, func, NULL))
{
printf("fail, errno[%d, %s]\n", errno, strerror(errno));
break;
}
printf("pthread create succeed[%d]\n", i);
}
return 0;
}
运行结果如下:
通过运行结果可以看出,未及时释放线程导致内存资源耗尽,进而导致线程创建失败。
但如果在「第 23 行」添加pthread_join(th, NULL);
代码,则可以避免这种情况的发生。
3.4 pthread_detach()
函数原型:int pthread_detach(pthread_t thread);
头 文 件:#include <pthread.h>
功能介绍:从状态上实现线程分离
参数介绍:线程标识符
返 回 值:
- On success, pthread_detach() returns 0
- On error, it returns an error number
在「3.3.5 及时释放资源」时提到了两个概念:线程分离状态和线程非分离状态。默认创建的线程为非分离状态,那么如何设置线程为分离状态呢?有两种方式:
- 调用
pthread_detach()
函数。 - 通过
pthread_create()
函数的第二个参数来设置线程分离。
一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join()
获取它的状态为止(或者进程终止被回收了)。但是线程也可以被置为 detach 状态;如果线程被设置为了分离状态,那么该线程主动与主控线程断开关系。线程结束后(不会产生僵尸线程),其退出状态不由其他线程获取,而直接自己自动释放。
#include <stdio.h>
#include <pthread.h>
#include <errno.h>
#include <string.h>
/* 线程函数,不作任何操作 */
void *func(void *arg)
{
return NULL;
}
int main()
{
int i;
for (i = 1; ; i++)
{
pthread_t th;
if (0 != pthread_create(&th, NULL, func, NULL))
{
printf("fail, errno[%d, %s]\n", errno, strerror(errno));
break;
}
pthread_detach(th);//使用 pthread_detach 函数实现线程分离
printf("pthread create succeed[%d]\n", i);
}
return 0;
}
注意:不能对一个已经处于 detach 状态的线程调用 pthread_join(),这样的调用将返回 EINVAL 错误。