[Linux 多线程(上)] 线程概念&线程控制&线程创建&线程终止&线程等待&线程控制

本文介绍Linux下的线程概念、创建及管理方法,包括线程的创建、等待、终止等操作,并探讨线程与进程的关系。

BingWallpaper

image-20220719121037513

image-20220713160901778

线程快速入门

在操作系统中,如果我们执行了某一应用程序,那么操作系统就会对这个应用程序创建一系列的资源以用来让这个程序在操作系统中运行起来。而整个创建过程以及创建成功后所产生的资源,我们将其称为一个进程。

所以说,进程是操作系统分配资源的基本单位。而线程通俗来讲就是一个进程中一个执行流。这里以串行与并行下载文件举例,如果我们使用串行的方式去下载多个文件,那么得到的结果是,将这些文件逐个按个的下载,即上一个下载完成之后才会下载接下来的文件。如果使用并行的方式下载,那么这些文件就会一次同时下载多个文件,而不是等待上一个下载完后才继续下载接下来的,大大的提高了下载效率。

通过上述例子,可以看出一个进程中可以同时执行多段程序代码片段,而这种同时有多个程序片段在执行就称为多线程。而其中的每一个执行流就被称为一个线程。

如果我们在创建“进程”时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:

image-20221005190149485

此时我们创建的实际上就是四个线程:

  • 其中每一个线程都是当前进程里面的一个执行流,也就是我们常说的“线程是进程内部的一个执行分支”。
  • 同时我们也可以看出,线程在进程内部运行,本质就是线程在进程地址空间内运行,也就是说曾经这个进程申请的所有资源,几乎都是被所有线程共享的。

image-20221005190355748

线程基本概念

🌿 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”

🌿 一切进程至少都有一个执行线程

🌿 线程在进程内部运行,本质是在进程地址空间内运行

🌿 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化

🌿 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流

线程三推六问

有了线程的概念之后,我们对进程又有了新的概念,也就是说,站在内核角度:承担分配系统资源的基本实体,叫做进程

✈️ 如何重新理解进程?线程在哪里运行?数据共享吗?

  1. 所有的进程都是只有一个task_struct,进程内部,只有一个执行流的话,就是之前所学的进程
  2. 线程在进程地址空间内运行
  3. 一个线程将共享父进程的所有全局变量和文件描述符,这允许程序员在一个进程中轻松地分离多个任务。

🍼 普通的操作系统,当线程足够多的时候,OS要不要管理线程?

如果支持真的线程,要!

要是来一套线程的管理机制的话,要再搭一套吗,我们的Linux是复制了进程的管理机制,但是windows是真的分开来的设计了线程

🛶 在Linux中,站在CPU的角度,能否识别task_struct是进程?还是线程?

不能,也不需要了,CPU只关心一个一个的独立执行流!线程的观点本来就是从进程转化而来的

在CPU看来,task_struct就是os原理上面的进程控制块

image-20221005191627925

💃 Linux下存在真正的多线程吗?

Linux下,并不存在真正的多线程,而是用进程模拟的!Linux中的所有执行流,都叫做轻量级进程! !

既然Linux并没有真正意义的线程,所以,Linux也绝对没有真正意义上的线程相关的系统调用!!

但是提供创建轻量级进程的接口,创建进程,共享空间!

vfork():父子共享空间

🔌 线程站在用户角度呢?

我创建线程,原生线程库的方案解决是的基于轻量级进程的系统调用

而我们Linux用的是在用户层模拟实现一套基于操作系统的线程接口,pthread

对于windows可能是有一个相应的真的系统库

image-20220713193821391

🎁 为什么我们C语言中定义一个字符串常量,不让我们修改?

image-20220713205535417

线程的优点

🌸 创建一个新线程的代价要比创建一个新进程小得多

🌸 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

🌸 线程占用的资源要比进程少很多(复用)

🌸 能充分利用多处理器的可并行数量

🌸 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(进程也可以做到,但是多线程用的多)

🌸 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

🌸 I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

计算密集型:计算为主 e.g.加密解密,排序,查找

I/O密集型:执行流执行的大部分任务是以IO为主的 e.g. 刷盘,访问数据库,访问网络(百度网盘上传下载)

线程的缺点

🌿 性能损失

一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

🌿 健壮性降低

编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。(进程是有独立性的,但是线程有临界资源,线程安全问题更加重要)

🌿 缺乏访问控制

进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

🌿 编程难度提高

编写与调试一个多线程程序比单线程程序困难得多

线程异常

单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃

线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

线程用途

合理的使用多线程,能提高CPU密集型程序的执行效率

合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)

Linux进程V.S.线程

进程和线程

进程是资源分配的基本单位

线程是调度的基本单位

  • 线程共享进程数据,但也拥有自己的一部分数据:
    • 线程ID
    • 一组寄存器(说明线程是调度的基本单位,只要调度需要切换就需要保存上下文数据)
    • (线程运行时会产生临时数据,需要把这些数据压栈)
    • errno
    • 信号屏蔽字
    • 调度优先级

多线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的

如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

线程和进程关系图

image-20220713213031194

image-20220713213108781

Linux线程控制

POSIX线程库

POSIX 实际上不是一个东西。它描述了一个东西——很像一个标签。想象一个标有: POSIX的盒子,盒子里面是一个标准。标准由 POSIX 关注的规则和指令集组成。POSIXPortable Operating System Interface的简写。它是一个 IEEE 1003.1 标准,定义了应用程序(以及命令行 shell 和实用程序接口)和 UNIX 操作系统之间的语言接口。POSIX是一个和Linux强关联的库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的

  • 要使用这些函数库,要通过引入头文<pthread.h>

  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

pthread_t和进程地址布局

我们知道线程很多,需要被管理的!那么就应该先描述,再组织
Linux不提供真正的线程,只提供LWP,意味着OS只需要对LWP内核执行流进行管理,那么,供用户使用的接口等其他数据,应该由谁来管理呢?线程库pthread库来管理!
image-20220714193143889

pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

image-20220719122144898

我们说在动态库中,提供了这样的每一个线程都有的结构,要找到用户级线程只要找到描述这个用户级线程的内存块的起始地址,拿到线程的数据就可以全部找到线程了,所以说先描述后组织只要交给线程库就可以了

往往 Java或是Python等等的语言的线程库基本都是在次基础上的一个封装,这些语言的使用成本会更加低一点,相较于原生库

mmap拓展延申:

阿里二面:什么是mmap?

认真分析mmap:是什么 为什么 怎么用

创建线程

功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);

参数:

thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数(回调函数)
arg:传给线程启动函数的参数

返回值:

成功返回0;失败返回错误码

错误检查:

传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。

pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回

pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小

📥**测试用例1:**主线程和创建出的线程分别打印输出

void *Routine(void *arg)
{
    char *msg = (char *)arg;
    while (1)
    {
        /* code */
        printf("%s\n", msg);
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, Routine, (void *)"thread 1");

    while (1)
    {
        printf("I am main thread\n");
        sleep(1);
    }
    return 0;
}

线程操作实现了可以打印两个while循环

image-20220714140653905

线程ID

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:

pthread_t pthread_self(void);

👖 **测试用例2:**证明线程本质都是同一个进程不同的执行流,所以属于一个进程,但是不同的线程,创建一批线程,显示线程自己的ID,并学习一个查看线程方法

这里pthread_self()显示的是用户级原生线程ID

void *Routine(void *arg)
{
    char *msg = (char *)arg;
    while (1)
    {
        printf("%s: pid:%d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
        sleep(1);
    }
}

int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char *buffer = (char *)malloc(64);
        sprintf(buffer, "thread %d", i);
        pthread_create(&tid[i], NULL, Routine, (void *)buffer);
        printf("%s tid is: %lu\n", buffer, tid[i]);
    }
    while (1)
    {
        printf("main thread: pid:%d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
        sleep(1);
    }
    return 0;
}

image-20220714151354347

新命令

ps -aL | grep [name]

image-20220714142210969

应用层的线程和内核 LWP是1:1的

线程需要被等待

线程也是需要被等待的,不等待的话可能也会造成僵尸进程一样的后果

image-20220714161317025

😆 为什么线程需要等待?

已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
创建新的线程不会复用刚才退出线程的地址空间。

功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **retval);
参数
thread:线程ID
retval:它指向一个指针,后者指向线程的返回值,可以拿到被等待线程的退出码
返回值:成功返回0;失败返回错误码

调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。也就是-1
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

🛴 **测试用例3:**创建一批线程之后,主线程等待其他线程结束,同时利用pthread_join获取退出码

void *Routine(void *arg)
{
    char *msg = (char*)arg;
    int count = 0;
    while(count  < 5){
        printf("%s: pid:%d, ppid: %d, tid: %lu\n", msg, getpid(), getppid(), pthread_self());
        sleep(1);
        count++;
    }
    return NULL;
    //return (void*)9012;
}

int main()
{
    pthread_t tid[5];
    for (int i = 0; i < 5; i++)
    {
        char *buffer = (char *)malloc(64);
        sprintf(buffer, "thread %d", i);
        pthread_create(&tid[i], NULL, Routine, (void *)buffer);
        printf("%s tid is: %lu\n", buffer, tid[i]);
    }
     for(int i=0; i < 5; i++){
         void *ret = NULL;
         pthread_join(tid[i], &ret);
         printf("thread %d[%lu] ... quit!,code: %d\n", i, tid[i], (int)ret);
     }
    return 0;
}

image-20220714153448410

这就完成了线程等待

📫pthread_join使用退出码,难道没有其他信息吗?初步处理异常?

其实线程本质是进程的一个执行流,我们说进程可以异常通过信号来终止,但是操作系统要异常终止一个线程的话,只能把整个进程一起终止,所以导致其他线程也终止了,这时候连返回你这个pthread_join的机会都没有了,所以不用接收退出信息了

所以线程健壮性不强,只要一个挂掉了,就进程一起挂了

线程终止

下面我们只讨论正常终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。exit代表的是终止整个进程
  2. 线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

🍢 一般情况,线程必须被等待,就如同子进程必须被等待一般!线程可以不被join吗?

也可以不用,不过需要将线程进行分离!!

pthread_exit
功能:线程终止
原型
void pthread_exit(void *retval);
参数
retval:retval不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码

🌃 线程可以自己取消自己吗?可以用其他线程取消主线程吗?

  1. 线程自己取消自己的话是可以的,但是最好是中间过程取消,而不是将亡的时候取消,取消成功后退出码拿到的是-1。我们一般还是线程去取消其他的线程,用main thread 取消其他线程推荐做法
  2. 主线程被取消了,但是我们不推荐,可能产生未定义问题image-20220714160355995

线程分离

🖌 线程分离之后,线程之间的关系

线程分离之后还是和之前的主线程在一个空间,公用同样的资源,然后区别在于它不需要主线程的等待,操作系统会自动回收资源

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

⚠️ joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

言之命至9012

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值