文章目录
线程
线程引入
进程
进程是资源管理的最小单位。当启动一个进程以后,会在系统中分配一个虚拟空间,虚拟空间中包括用户空间和内核空间。用户空间包括数据段、代码段、堆栈段,而在内核空间中会为每一个进程创建一个进程表项,当一个进程结束另外一个进程启动的时候,还会涉及到物理内存中的内容以及寄存器状态的替换,可见创建一个进程的开销是非常大的。
这里举一个例子,以现在的浏览器为例。它其实是基于BS架构的(BS架构就是基于浏览器/服务器架构,是一种基于互联网的网络架构模式,它通过浏览器作为客户端界面,将主要事务逻辑处理放在服务器端),当用户点击某个页面,然后服务器端给客户端响应一个页面回来。如果服务器端针对客户端的所有请求都去启动一个进程的话,那么它所消耗的资源是非常庞大的。尤其是在大并发的场合下,服务器端很快就会崩溃。
虽然说现在采用了写时拷贝技术,但是那个也仅仅是基于刚开始父子进程都没有对资源修改的时候共用一片物理内存,一旦有一方进行修改就会为该进程分配一片物理内存,并将之前的数据拷贝一份到这片内存中。所以如果以上的操作都是基于进程,那么它所占用的CPU的资源,内存是非常大的。所以这里就引入了线程,进程是线程的载体,所以可以说线程它和进程相比几乎是不占用资源的,它共享进程的所有资源。
线程
线程是程序执行的最小单位。线程是隶属于某一个进程里边的,它负责处理进程的某一个事务。它包含独立的栈和CPU寄存器状态,每个线程共享所附属进程的所有资源,包括打开的文件、内存页面、信号标识及动态分配的内存等。
进程和线程的关系
如图所示,简单介绍了一下进程和线程的关系,如果不通过pthread_create
函数创建子进程的话,当一个进程运行起来的时候就有一个线程就是主线程。线程是进程的执行流,如果只有一个主线程那么就只有一条执行流,如果使用pthread_create
函数创建多个线程后就有多条执行流。
当在进程运行的时候首先要进入到就绪状态 (runnable),然后系统调度会根据进程的优先级来调度进程,被选中的那个进程会分配时间片然后进入到运行状态 (running)。当时间片用完而进程没有执行完的时候会被挂起变成就绪状态 (runnable) 直到下一次获得时间片。通过上边可知进程的执行实际上就是里边线程的执行,如果该进程只有一个执行流的话,那么分配给进程的时间片就是分配给这个线程的时间片。但如果一个进程中有多个执行流,那么也就对应了多个线程,假如这里分配给该进程的时间片为100ns,那么线程的运行也要遵循系统的调度,并不是说某一个线程独占时间片去运行,而是所有的线程都会去获取时间片,它们的运行方式也是抢占式获得CPU来运行。分配给线程的所有时间片总和绝不会超过分配给进程的时间片,当分配给进程的时间片用完以后,线程也会从运行状态返回给就绪状态。
进程和线程相比优缺点
-
进程(Process)
-
优点
- 独立性强:每个进程都有自己的地址空间、堆栈和数据段,它们相互独立。这样可以避免一个进程的崩溃影响到其他的进程
- 安全性高:由于进程之间的地址空间相互隔离,系统更容易保证一个进程的操作不会干扰到其他的进程,从而提高了安全性。
- 更好的稳定性:进程崩溃时只会影响自身,而不会影响到其他进程,这提高了系统的稳定性。
通过之前进程那一章可以发现,当使用
kill
去杀死一个进程的时候并不会影响别的进程,所以可以看出进程的优点就是健壮性,它们之间的内存空间相互独立,所以一个进程的崩溃并不会干扰到其他的进程 -
缺点
- 创建和切换开销大:进程的创建和切换要比线程消耗更多的资源。由于进程间的地址空间相互隔离,切换进程需要保存和恢复更多的状态信息。之前有讲过进程之间的切换需要将物理内存中的内容全部替换成要执行的进程的内容,同时也包括一些寄存器状态的设置,所以创建一个进程的资源开销非常大。
- 通信复杂:进程间的通信(IPC)通常比线程间的通信更复杂且效率低。需要通过消息队列、管道、共享内存等机制来实现。
- 资源消耗高:每个进程都需要独立的系统资源,如内存、进程表项和文件描述符,这会增加系统的资源消耗。
-
-
线程(Thread)
- 优点
- 轻量级:线程创建和切换的开销比进程小,因为线程共享同一进程的地址空间和资源。线程的调度和上下文切换速度较快。
- 共享资源:线程间可以直接共享进程的资源,比如内存和文件描述符,这使得线程间的数据交换和通信更加高效。
- 响应速度快:线程能够更快地响应任务,因为线程间的切换比进程间的切换更轻量。
- 缺点
- 安全性低:由于线程共享进程的地址空间,一个线程的崩溃或错误可能会影响到其他线程,甚至导致整个进程崩溃。
- 同步复杂:线程共享同一地址空间,这可能导致数据竞争和同步问题。开发者需要使用锁机制等同步工具来保证线程安全,增加了程序的复杂性。
- 调试困难:多线程程序的调试比单线程程序更复杂,因为线程间的并发性会导致难以重现和排查问题。
- 优点
-
总结
- 进程适合需要高度隔离、资源隔离和稳定性的场景,比如运行多个独立的应用程序。
- 线程适合需要高效、轻量级的任务并发处理,比如再一个应用程序内部处理多个任务或者提高响应速度。
线程的分类
- 线程按照器调度者可分为用户级线程和内核级线程两种
- 用户级线程:主要解决的是上下文切换的问题,其调度过程有用户决定(什么时候创建线程,什么时候终止线程取决于用户)
- 内核级线程:由内核调度机制实现(线程创建好后由就绪态变为运行态取决于内核级线程)
现在的线程都是内核级线程和用户级线程绑定的,也就是说当内核级线程被调度从就绪态变为运行态用户级线程也就被从就绪态变为运行态。进程分配给线程的时间片其实是分配给了内核级线程,用户级线程的时间片是以内核级线程的时间片为基准的。
默认情况下用户级线程和内核级线程是一对一进程绑定的,但是也有多对一的情况,只不过这种情况下的实时性就会变差,因为它要去考虑它所对应的哪一个用户级线程要被调用,会涉及到线程之间的切换以及判断这中间也会耗费时间。
当CPU分配给线程的时间片用完但是线程没有执行完毕,此时线程会从运行状态返回到就绪状态,将CPU让给其他线程使用。
线程的创建
线程标识
之前的每个进程都有唯一的进程标识符与之一一对应,就是进程的ID (process ID) ,那么线程也有它对应的标识符叫线程ID
- 每个进程内部的不同线程都有自己的唯一标识(线程ID)
- 线程表示只在它所属的进程环境中有效
- 线程标识是
pthread_t
数据类型
#include <pthread.h>
int pthread_equal(pthread_t, pthread_t);
//功能:判断两个线程是否是同一个线程
//参数1:线程1的标识符
//参数2:线程2的标识符
//返回值:相等返回非0,否则返回0
#include <pthread.h>
pthread_t pthread_self(void);
//功能:获取调用者的线程id
线程创建
#include <pthread.h>
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_routine)(void*), void *restrict arg);
//功能:创建新线程
//参数1:线程标识符指针,用于存储新创建的线程的ID
//参数2:线程属性指针,用于设置线程的属性,如果想要保持默认属性,设置为NULL
//参数3:线程运行函数的起始地址,新创建的线程从此位置开始运行。此函数是一个返回值是void*,并且接受一个void*为参数的函数
//参数4:主线程传递给子线程的参数,参数类型为void*
//返回值:成功创建线程返回0,否则返回一个错误编码
//注意:当主线程调用pthread_create函数创建子线程以后,线程之间的顺序是不可预见的,它们遵循系统调度,如果想要某个线程先执行,可以使用sleep函数去阻塞其他线程
示例–龟兔赛跑
#include "header.h"
void* exec_func(void *arg)
{
int cnt = *((int *)arg);
int i;
int tim;
srand48(time(NULL)); //设置随机数种子
for(i=1; i<=cnt; i++)
{
tim = drand48() * 20000; //drand48()会获取0-1之间的随机数
printf("pthread id:%lx i = %d\n",pthread_self(),i);
usleep(tim); //将tim作为参数传进去就是毫秒级延时
}
return NULL;
}
int main(void)
{
int err = -1;
int cnt = 50;
pthread_t rabbit,turtle;
//创建rabbit线程
if((err = pthread_create(&rabbit, NULL, exec_func, (void*)&cnt)) != 0)
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
//创建turtle线程
if((err = pthread_create(&turtle, NULL, exec_func, (void*)&cnt)) != 0)
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
printf("main control pthread id is %lx\n",pthread_self());
//调用pthread_join函数的线程将会阻塞,直到所有的线程都退出被阻塞的线程才会由等待状态
//转为就绪状态,然后根据系统调度变为运行状态
pthread_join(rabbit, NULL);
pthread_join(turtle, NULL);
printf("finished\n");
return 0;
}
由于这里使用到了pthread
线程库,所以要在最后加上-lpthread
链接选项,要不然会提示pthread_create
函数为定义。通过编译执行可以发现三个线程的执行顺序是不确定的,线程之间的运行顺序是由线程调度来决定的。并且这里需要注意的是在代码中要防止主线程在子线程前边退出,因为在代码的最后调用了return
,前边讲过return
和exit
函数都是进程的终止方式。所以如果主线程执行到return
那就表明进程退出了,前边有讲过进程是线程的载体,如果进程退出了那么所有的子进程也都会退出,因此这里调用pthread_join
函数阻塞主线程,使得主线程在所有的子线程执行完毕后再执行就可以保证所有的子进程都能够正常的执行完毕。
创建线程后内存空间的变化
由于是进程创建了线程,所以线程共享进程的大部分资源,其中包括代码段、数据段(全局变量、静态变量)、堆区,但是线程有自己独立的栈空间。在线程内部定义的局部变量属于它自己独立的栈空间,对它进行修改并不会影响到另外的进程,例如这里的i和r局部变量。而位于数据段的全局变量和静态变量是共享的,也就是说一方修改会同步到整个进程,对共享资源的操作一定要注意,这就是后边的线程之间的同步和互斥。所以在进行线程编程的时候,可以尽量使用局部变量,使用全局变量和静态变量要使用互斥锁和条件变量等操作来实现线程之间的同步和互斥。
线程终止
-
主动终止
- 线程的执行函数中调用
return
语句 - 线程调用
pthread_exit
函数
- 线程的执行函数中调用
-
被动终止
- 线程可以被同一进程中的其他线程取消,其他线程调用
pthread_cancel()
函数
- 线程可以被同一进程中的其他线程取消,其他线程调用
#include <pthread.h>
int pthread_cancel(pthread_t tid);
//功能:用于向指定线程发送一个终止线程运行请求
//参数:要取消的线程的线程标识符
//返回值:成功执行返回0否则返回一个错误码
#include <pthread.h>
void pthread_exit(void *retval);
//功能:用于终止一个线程的运行,并返回一个指向特定状态或数据的指针给调用者
//参数:void类型的指针,用于返回线程退出时的状态和数据
#include <pthread.h>
int pthread_join(pthread_t thread, void **value_ptr);
//功能:等待子线程退出并回收子线程所使用的资源,包括栈空间和线程局部存储,调用该函数的线程将会被阻塞,直到子线程退出才会继续执行。
//参数1:要等待的线程标识符
//参数2:指向一个指针,用于存储现成的返回值。如果不关心线程的返回值,可以直接传递NULL
//返回值:如果成功执行返回0,否则返回错误码
这里需要注意的是上边的这些函数不管是主动退出函数被动退出都是线程的终止函数,而前边的exit|_exit|_Exit
这些函数都是进程的终止函数,一旦调用这三个中的任意一个,整个进程就直接退出了。
线程终止后它所占有的资源如何变化
不管是线程主动退出还是现成的被动退出,线程所占有的资源并不会随线程结束而释放。所有的资源必须等整个进程结束以后才会释放,如果想要在线程结束后立即释放线程锁占有的资源那么就要调用pthread_join
函数来等待子线程退出并将子线程的资源释放。这个调用和前边的wait
函数十分类似,由于子进程退出没有完全释放资源,还在内核空间中留有进程表项,所以子进程会变成僵尸进程。为了防止子进程变成僵尸进程,父进程要调用wait
函数来等待子进程退出,最后通知内核将子进程的资源释放。而这里的子线程退出后资源也不会释放会变成“僵尸线程”,所以要在主线程中调用pthread_join
函数来等待子线程退出并回收它的资源。
示例–线程终止(子线程将普通变量返回给主线程)
#include "header.h"
typedef struct
{
int data1;
int data2;
}ARG;
void* exec_func(void *arg)
{
ARG *r = (ARG*)arg; //将主线程传过来的参数转为ARG类型
int *retval = (int *)malloc(sizeof(int)); //在堆上开辟空间,不会随线程退出而释放
if(retval == NULL)
{
perror("malloc error");
exit(EXIT_FAILURE);
}
*retval = r->data1 + r->data2;
pthread_exit((void*)retval); //将结果返回给主线程,主线程使用pthread_join函数进行接收
}
int main(void)
{
int err = -1;
int *retval = NULL;
pthread_t tid;
ARG arg = {10, 30};
if((err = pthread_create(&tid, NULL, exec_func, (void*)&arg)) != 0)
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
if(pthread_join(tid, (void **)&retval) != 0)
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
//经过pthread_join函数以后修改了retval指针变量的指向,指向了子线程40的那片空间
if(retval != NULL)
{
printf("receive the retval from the thread is %d\n",*retval);
free(retval); //当用完以后将堆空间释放,防止造成内存泄漏
retval = NULL;
}
return 0;
}
通过编译执行可以发现主线程通过pthread_join
函数确实是获取到了来自子线程的返回值。这里需要注意的是pthread_join
函数的第二个参数比较难以理解,这里详细解释下:由于它的函数原型是int pthread_join(pthread_t thread, void **value_ptr);
,它的第二个参数是一个二级指针,所以这里在定义的时候就要定义一个一级指针,然后将一级指针进行取地址就变成了一个二级指针。关于它能够获取到子线程的返回值的原因是它将retval
的指向修改了,之前的retval
指向NULL
,现在指向子线程在堆上开辟出来的那片空间,因为在堆空间上开辟的空间是不会自动释放的,所以主线程才能够拿到子线程的返回值(刚开始retval = NULL
,然后在pthread_join
函数内部将retval = 0x123456(堆空间的地址)
)。这里修改一级指针的指向要使用二级指针,就好比要通过函数调用修改普通变量的值要用到一级指针一样,都要直接操作它的内存空间。
示例–代码优化(龟兔赛跑)
#include "header.h"
typedef struct
{
int start;
int end;
int time;
int distance;
char name[32];
}ARG;
void* exec_func(void *arg)
{
int i;
ARG *r = (ARG*)arg;
ARG *ret = (ARG*)malloc(sizeof(ARG));
if(ret == NULL)
{
perror("malloc error");
exit(EXIT_FAILURE);
}
*ret = *r; //将栈空间r的结构体内容拷贝一份到堆空间的ret
for(i = r->start; i<= r->end; i++)
{
printf("[%s id:%lx] i = %d\n",r->name,pthread_self(),i);
usleep(r->time);
}
ret->distance = ret->end - ret->start;
pthread_exit((void*)ret);
}
int main(void)
{
int err = -1;
pthread_t rabbit,turtle;
ARG *retval = NULL;
srand48(time(NULL)); //设置随机数种子
ARG r_g = {5, 50, drand48() * 100000, 0, "rabbit"}; //设置主线程传给兔子线程的参数
ARG t_g = {1, 60, drand48() * 50000, 0, "turtle"}; //设置主线程传给乌龟线程的参数
//drand48()函数获取0-1之间的随机数,然后传给usleep函数作为延时时间
if((err = pthread_create(&rabbit, NULL, exec_func, (void*)&r_g)) != 0) //创建兔子线程
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
if((err = pthread_create(&turtle, NULL, exec_func, (void*)&t_g)) != 0) //创建乌龟线程
{
perror("pthread_create error");
exit(EXIT_FAILURE);
}
printf("main control thread id is %lx\n",pthread_self()); //获取主线程的线程ID
pthread_join(rabbit, (void**)&retval); //等待子线程退出并回收子线程的资源同时获取子线程传给主线程的返回值
if(retval != NULL) //判断retval不为空则说明pthread_join函数将retval指向了子线程开辟的堆空间,用完后要使用free函数释放
{
printf("rabbit distance is %d\n",retval->distance);
free(retval);
retval = NULL;
}
pthread_join(turtle, (void**)&retval);
if(retval != NULL)
{
printf("turtle distance is %d\n",retval->distance);
free(retval);
retval = NULL;
}
return 0;
}
通过编译执行可以将龟兔赛跑的距离输出说明主线程收到了来自子线程的返回值。这里需要注意的一点是:在子线程中的ARG *r = (ARG*)arg
这句话实际上是定义了一个局部变量,局部变量存储在栈区,所以在主线程中调用pthread_join
函数以后子线程它的所有资源都会被释放掉,这样位于栈区中局部变量的数据就不再有效。所以这里要将它的数据保存在堆区,堆的生命周期由用户决定,使用malloc
函数在堆区开辟一个新空间,然后使用*ret = *r
将栈区的数据拷贝一份到堆区,最后将堆区的结构体指针返回,这样就不会有数据无效的风险。当然,在主线程中使用完数据后使用free
函数将这片空间释放防止造成内存泄漏。