🏠关于专栏:Linux的浅学到熟知专栏用于记录Linux系统编程、网络编程等内容。
🎯每天努力一点点,技术变化看得见
文章目录
线程引入
什么是线程
首先我们一起回顾一下之前学习的进程:在之前学习的单进程程序中,整个进程执行过程中只有一个执行流。
【例子】单执行流之打开记事本
当我们打开记事本时,进程执行IO操作,从磁盘将对应文件加载到内存,再将数据显式到显示器中,这整个过程只需要一个执行流就够了。
【例子】迅雷实现边下载边播放
一个程序启动时会创建一个进程,如果既要下载又要播放视频的话,一个执行流就显得力不从心了。也许你会想到,可以创建一个子进程,一个进程进行下载,一个进程执行播放视频的操作不就可以了吗?但实际实现时,迅雷并没有采用创建子进程的方式,因为创建进程会产生大量的开销。
进程创建会被分配PCB(进程控制块),虚拟地址空间及多级页表,还有文件描述符表等。可不可以实现多个进程共享一份进程资源呢?上述资源中的虚拟地址空间、多级页表、文件描述符表适合多个进程共享。而能够共享这些资源的,就是线程,咱一起来认识认识↓↓↓
在一个程序里的一个执行路线就叫做线程。更准确的定义:线程是“一个进程内部的控制序列”。一切进程至少有一个执行线程。
线程在进程内部运行,本质是在进程地址空间内运行。在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化(因为多个线程共享一个进程虚拟地址空间、页表、文件描述符表等资源)。透过进程虚拟地址空间可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。↓↓↓
Linux没有真正意义上的线程,而是用“进程“模拟的线程。因而,Linux中的执行流被称为轻量级进程。
Linux的实现方案:
Ⅰ 在Linux中,线程在进程“内部”执行,线程在进程的地址空间内运行(因为任何执行流要执行都要有资源!地址空间是进程的资源窗口,线程需要借助进程地址空间访问资源)
Ⅱ 在Linux中,线程的执行粒度要比进程要更细,因为线程仅执行进程代码的一部分
★ps:Linux实现线程时并没有给线程创建独立的内核数据结构,而是使用与进程相同的数据结构。但在Windows下,线程和进程的内核数据结构是独立的,即Windows为线程设计了独立的内核数据结构。
学习到这里,我们需要重新定义线程和进程↓↓↓
什么叫做线程/进程?我们认为,线程是操作系统调度的基本单位。内核观点:进程是承担分配系统资源的基本实体。
如何理解我们以前的进程?
操作系统以进程为单位,给我们分配资源,我们当前的进程内部,只有一个执行流。所以,我们之前学习的进程就是具有单执行流(即单线程)的进程。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多。
因为多个线程共享当前进程的虚拟地址空间、页表、文件描述符表等资源,这些资源不需要被独立分配,进而降低了创建线程的代价。
- 与进程之间的却换相比,线程之间的切换需要操作系统做的工作要少得多。
为什么线程切换的成本更低呢?在CPU中,包含L1到L3,共三层缓存,这三层缓存会根据局部性原理将内存中的代码和数据预读到CPU内部。在同一个进程内切换线程时,不需要切换地址空间、页表等,进而不会引起三级缓存中数据的频繁切换。但如果进程切换,Cache中的数据会立即失效,新进程进入CPU后,需要重新缓存。
- 线程占用资源要比进程少很多
在同一个进程内部的线程会共享全局数据、全局函数、地址空间、页表等资源,故占用资源会比进程少。
- 能充分利用多处理器的可并行数量
一个处理器每个时刻可以跑一个进程,但由于线程的存在,一个进程中会包含多个线程,即多个执行流。若当前有3个CPU处理器,而每个进程包含10个线程,则会有30个执行流并发执行。相比于单执行流的进程,具有多线程的进程可以使得当前并发执行的执行流数量增大。
- 在等待慢速I/O操作结束得同时,程序可执行其他的计算任务
如果某个进程执行的任务是:将计算结果让打印机打印出来。由于打印机是外设,且速度非常慢。我们可以让一个执行流(一个线程)执行IO操作,而另一个执行流(线程)执行计算操作。这样可以避免像单执行流下,等待打印机IO结束才继续计算结果。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
若某个程序需要计算X/Y,其中X和Y的结果计算出来均需要大量的时间。我们可以实现多线程,让两个线程分别计算X和Y,且每个线程在一个处理器上计算,带计算结束后,再回到主执行流执行除法操作。
- I/O密集型应用为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作。
若某个程序需要使用显示器显式视频,用扬声器播放声音,由于它需要等待两个外设,我们可以拆分出三个线程,A线程处理当前要播放的视频和声音数据;B线程负责将A线程的视频数据发送给显示器,并等待显示器的缓慢IO操作;C线程负责将A线程的声音数据发送给扬声器,并等待扬声器的慢速IO操作。这样可以提高整体效率。
★ps:为什么线程比进程更加轻量化?
a. 创建和释放更加轻量化
b. 切换更加轻量化
线程的缺点
- 性能损失
一个很少被外部事件阻塞(IO、中断等事件)的计算密集型线程往往无法与其他线程共享一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
例如:在只有一个CPU的计算机中,位于同一进程中的A、B线程执行过程只需要CPU运算即可,不会出现访问外设、产生中断等事件。假设CPU单次调度给每个进程的时间为10ns,而进程内部线程切换需要1ns。如果一个进程包含两个计算密集型线程,则一次CPU调度,两个线程只能执行9ns;但若是一个进程只包含一个计算密集型线程,则一次CPU调度,线程可以执行10ns,可以避免1ns的线程切换消耗。
-
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不同的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 -
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 -
编程难度提高
编写与调试一个多线程程序比单线程程序难得多。
线程异常
单个线程如果出现除零、野指针问题导致线程崩溃,整个进程也会随着崩溃。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程用途
●合理的使用多线程,能提高CPU密集型程序的执行效率(在多处理器的情况下,优势明显)
●合理的使用多线程,能提高I/O密集型程序的用户体验(如生活中,我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
进程vs线程
※ 进程是资源分配的基本单位
※ 线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据:
●线程ID
●一组寄存器(线程上下文)
●栈
●errno
●信号屏蔽字
●调度优先级
进程的多个线程共享同一地址空间,因此代码段、数据段都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中也都可以访问到;除此之外,各线程还共享以下进程资源和环境:
●文件描述符表
●各种线程的处理方式(SIG_IGN、SIG_DFL或者自定义各信号处理函数)
●当前工作目录
●用户id和组id
★ps:如何看待之前学习的单进程?具有一个线程执行流的进程
Linux线程控制
内核中没有很明确的线程概念,只有轻量级进程的概念。因而系统并没有给我们提供线程的系统调用接口,而只给我们提供了轻量级进程的调用接口。
为了使用方便,就诞生了第三方库pthread,该库对轻量级进程接口进行了封装,为用户提供直接的线程调用接口。而这个库几乎在所有Linux平台中,都是默认自带的。
POSIX线程库
●与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
●要使用这些函数库,要通过引入头文件<pthread.h>
●链接这些线程函数库时,要使用编译器命令的"-lpthread"选项
★ps:pthread库属于第三方库,但它的头文件和库文件已经放在gcc/g++能自动查找的路径下,故不用显式指明;但使用第三方库必须指明第三方库的名称。关于第三方库的使用,可以查看该文章=>动静态库的制作与使用
创建线程(pthread_create函数)
该函数的第一个参数为输出型参数,用于返回创建成功时,返回线程id(tid);第二个参数用于设置线程的属性,attr设置为NULL表示使用默认属性;第三个参数start_routine是个函数地址(start_routine指向的函数要求返回值和参数均为void*),用于指定线程启动后要执行的函数;第四个参数arg表示传给线程启动函数的参数。
pthread_create线程创建函数创建成功的返回值为0,失败返回错误码。
线程创建错误检查:
●传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误;
●pthread函数出错时不会设置全局变量errno(而大部分其他POSIX函数也会这样做)。而是将错误码通过返回值返回;
●pthreads同样也有线程内部自己的errno变量(errno变量每个线程都是独立的),以支持其使用errno的系列代码。对于pthread函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。
下面代码演示了如何创建线程↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* Rountine(void* args)
{
while(1)
{
printf("I am new thread!!!\n");
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
if(pthread_create(&tid, NULL, Routine, NULL) != 0)
{
perror("thread create error");
exit(1);
}
while(1)
{
printf("main thread is running...\n");
printf("tid = %p\n", tid);
sleep(1);
}
return 0;
}
我们可以使用ps -aL | head -1 && ps -aL | grep pthread_create
查看当前系统中的线程↓↓↓
★ps:LWP(Light Weight Process,轻量级进程)就是线程id,LWP等于PID的为主线程,但为什么和上面打印出来的tid数值不一样呢??
下面来讨论一下线程ID,即tid↓↓↓
pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和ps -aL中的线程ID不是一回事。前面讲的线程ID属于进程调用的范畴,因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_create函数第一个参数指向一个虚拟内存空间,该内存空间的地址即为新线程的线程ID,属于NPTL线程库的范围。线程库的后序操作,就是根据该线程ID来操作线程的。
★ps:NPTL(Native POSIX Thread Library)是Linux操作系统中的一个本地POSIX线程库。
pthread_t到底是什么类型取决于实现,对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的地址。每个线程在pthread库级别的tid,就是线程存储的起始地址。
★ps:除了主线程,所有的其他线程也有自己独立的栈结构
线程库NPTL提供了pthead_self函数,可以获得线程自身的ID。
下面代码演示了pthread_self函数的使用↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* args)
{
printf("new thread's id = %p\n", pthread_self());
return NULL;
}
int main()
{
pthread_t tid;
if(pthread_create(&tid, NULL, Routine, NULL) != 0)
{
perror("thread create error\n");
exit(1);
}
while(1)
{
printf("main thread's id = %p\n", pthread_self());
sleep(1);
}
return 0;
}
再查看一下当前线程情况,可以发现,新线程会自动退出并释放资源,不需要主线程进行管理。↓↓↓
★ps:若主线程比其他线程先行退出,则进程资源会被释放,则进程内的其他线程均会退出。
线程终止(pthread_exit/pthread_cancel函数)
我们先来看一段代码及其执行结果↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* args)
{
printf("new thread use exit\n");
exit(0);
return 0;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while(1)
{
printf("I am main thread\n");
sleep(1);
}
return 0;
}
上述代码中,新线程执行了exit,导致整个进程终止。如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。(这种方法对主线程不适用,主线程从main函数return相当于调用exit,所有线程将会退出;若从普通函数return,则表示函数该普通函数执行结束)
- 线程可以调用pthread_exit终止自己
- 一个线程可以调用pthread_cancel终止一个进程中的另一个线程
pthread_exit用于终止当前线程,传入的参数是当前线程要返回的数据,关于这个参数将于下方讨论线程等待时详述。如果没有数据要返回,直接填写NULL即可。
下面代码演示了线程退出函数的使用↓↓↓
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* args)
{
int cnt = 5;
while(cnt--)
{
printf("I am new thread, tid = %p\n", pthread_self());
sleep(1);
}
printf("new thread quit\n");
pthread_exit(NULL);
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while(1)
{
printf("main thread is running, tid = %p\n", pthread_self());
sleep(1);
}
return 0;
}
我们可以通过while :; do ps -aL | head -1 && ps -aL | grep pthread_exit; sleep 1; done;
来每秒查看一次线程状态↓↓↓
那如果是A线程要杀死B线程呢?则需要使用pthread_cancel函数↓↓↓
它的参数thread,需要传入带杀死的线程的tid。
下面代码演示了主线程杀死新创建的线程↓↓↓
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* args)
{
int cnt = 1;
while(1)
{
printf("%d->new thread's tid = %p\n", cnt, pthread_self());
sleep(1);
cnt++;
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
int cnt = 1;
while(cnt <= 10)
{
printf("%d->I am main thread\n", cnt++);
if(cnt == 5) pthread_cancel(tid);
sleep(1);
}
return 0;
}
等待线程(pthread_join函数)
如果主线程提前退出,会导致整个进程内的所有线程全部退出。因而,主线程需要等待各个线程退出后才能退出。同时,如果其他线程退出,主线程需要回收对应线程的资源,否则会导致内存泄漏等问题,那要用什么接口来等待,回收对应线程呢?↓↓↓
pthread_join的第一个参数是被等待线程的tid,第二个参数retval就是线程执行函数的返回值。
线程执行函数要求传入的参数为void*类型,返回值为void*类型。线程执行函数如果想返回某些数据,可以使用return,也可以在调用pthread_exit时,将返回值传入pthread_exit中。pthread_join可以通过函数第二个参数获取返回值。
下面代码演示了pthread_join的用法,同时也演示了如何给线程执行函数传参,及如何使用线程执行函数返回数据↓↓↓
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
struct data
{
char text[1024];
int num;
};
void* Routine(void* args)
{
const char* msg = (char*)args;
int cnt = 5;
while(cnt--)
{
printf("%d->new thread recieve: %s\n",cnt, msg);
sleep(1);
}
struct data* d = (struct data*)malloc(sizeof(struct data));
sprintf(d->text, "jammingpro");
d->num = 888;
return (void*)d;
}
int main()
{
pthread_t tid;
const char* msg = "main thread's message";
pthread_create(&tid, NULL, Routine, (void*)msg);
struct data* ret;
pthread_join(tid, (void**)&ret);
printf("%s,%d\n", ret->text, ret->num);
return 0;
}
★ps:pthread_exit的参数用于返回数据给join等待该线程的其他线程,用法与上面代码类似,这里不再举例说明。
★ps:如果没有对线程做join操作,则对应线程会和之前讲解过的子进程退出一样,进入僵尸状态,即僵尸线程。Linux中虽然无法直接查看僵尸线程,因而,在编程过程中,一定要注意回收线程资源,以放置资源泄漏等问题。
分离线程(pthread_detach函数)
如果让主线程单纯等待各个线程的话,则就会白白浪费一个线程资源。我们可以让新线程与主线程分离,在新线程运行的过程中,主线程也能处理自己的任务。同时,新线程的资源不需要由主线程回收,该部分资源将由系统自动回收。
pthread_detach只需要待分离的线程的tid即可,但要注意的是,主线程无法将自己进行分离。
下面代码演示了线程分离操作↓↓↓
#include <stdio.h>
#include <pthread.h>
void* Routine(void* args)
{
int cnt = 5;
while(cnt--)
{
printf("%d->new thread's tid = %p\n", cnt, pthread_self());
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL,Routine, NULL);
pthread_detach(tid);
int cnt = 10;
while(cnt--)
{
printf("%d->I am main thread, I am doing my tasks...\n", cnt);
}
return 0;
}
🎈欢迎进入从浅学到熟知Linux专栏,查看更多文章。
如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d