目录
前言
2.线程控制
1.验证理论
2.引入pthread线程库
3.linux线程控制的接口
3.线程id及进程地址空间布局
4.线程栈
前言
本篇是紧接着上一篇的内容,在有了相关线程概念的基础之上,我们将要学习线程控制相关话题!!
2.线程控制
1.验证理论
先来验证一下我们上面的理论
创建线程可用pthread_create函数(不是系统调用)
第一个参数传pthread_t类型变量来获取新线程的id;第二个参数为线程属性(设置为nullptr就可以);第三个参数是传返回值为void*,参数为void *的函数指针;第四个参数就是想传递给第三个参数的指针/参数
然后我们正常链接是过不了的,因为这不属于系统调用,我们需要在链接时加上pthread第三方库名称才行,因为是第三方库,所以需要带上l选项—— -lpthread
使用命令:ps -aL来查看所有线程
其中pid是一样的,证明这两线程(两执行流)属于同一个进程;TTY表示终端,它们都属于同一个终端,都往显示器上打印;而LWP则是light weight process——轻量级进程,所以这两执行流的轻量级进程号分别是902075和902076,LWP和pid相等的那个是主线程
CPU调度的时候看的是LWP,调度只看轻量级进程,我们之前学的getpid虽然拿的是pid,但是在调度的时候拿的还是lwp,因为单进程的话,pid就是lwp嘛
细节问题:
-
关于调度的时间片问题:进程的时间片是等分给不同的线程的,因为时间片也是共享的(不可能说创建一个线程就拷贝一份时间片,那样如果有恶意程序不断分裂线程就会导致时间片是一直累加的)
-
我们可以验证一下线程异常的情况
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; void *threadrun(void *args) {string name = (const char *)args;while (true){sleep(1);cout << "我是新线程: name: " << name << ",pid: " << getpid() << endl;int a = 10;a /= 0;}return nullptr; } int main() {pthread_t tid;pthread_create(&tid, nullptr, threadrun, (void *)"thread-1");while (true) // 主线程往这里执行,新线程转而去执行我们的threadrun了{cout << "我是主线程..." << ",pid: " << getpid() << endl;sleep(1);}return 0; }
可以看到当新线程发生异常之后,系统终止的是整个进程——任何一个线程崩溃,都会导致整个进程崩溃,一个崩溃会影响其他人,所以健壮性低
-
为什么正常打印出的消息会混杂在一起
这是多线程程序输出混乱问题,因为多个线程(主线程、新线程 )共享标准输出流,CPU 调度线程时,没有加保护时,若一个线程输出未完成就切换到另一个线程继续输出,就会导致消息混杂
2.引入pthread线程库
为什么会有这个库,这个库是什么东西?
我们上面的pthread_create封装的就是底层系统的clone
我们在c++阶段也学习过创建线程的方式,在linux下其本质也是封装了pthread库,在windows下是封装了windows创建线程的接口,目的是为了保证语言的跨平台、可移植性,所以语言的跨平台或者可移植性一般都是大力出奇迹,所有平台全部干一份,然后条件编译形成库(所有的热门偏后端的语言基本多线程都这样封装的)
3.linux线程控制的接口
前面我们所提及的pthread库其实叫做POSIX线程库
• 与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的
• 要使⽤这些函数库,要通过引⼊头⽂ <pthread.h>
• 链接这些线程函数库时要使⽤编译器命令的“-lpthread”选项
-
创建线程函数pthread_create(我们在上面也谈过的)
[^] pthread_t类型其实是一个无符号长整型
在我们的新线程被创建出来之后,主线程和新线程谁先运行是不确定的,这一点在我们的fork创建子进程之后的父子进程之间的谁先运行的时候也是一样的
这里其实我们的参数3属于是回调函数的范畴了,说到回调函数,这里就不得不讲一下了
关于回调函数:
1. 回调函数的角色
pthread_create
是创建线程的系统调用,需要一个 “线程执行逻辑”,但它没办法直接把逻辑写死在函数里(要支持不同业务场景)。所以设计成让调用者传入一个函数指针,这个被传入的函数(routine
)就是 “回调函数”—— 由pthread_create
“回调” 执行,实现线程的自定义逻辑。2. 代码里的关键关联
pthread_create(&tid, nullptr, routine, (void *)"thread-1");
routine
的函数签名要求:必须符合pthread约定的线程函数原型void* ()(void)即:
返回值是
void*
(可用来给主线程返回数据)参数是
void*
(能兼容任意类型的入参,比如这里传字符串"thread-1"
)调用时机:
pthread_create
成功创建线程后,新线程会自动执行routine
函数,把(void *)"thread-1"
作为参数传入。解耦思想:
pthread_create
只负责 “线程创建 + 触发回调”,具体线程要做什么(routine
里的逻辑)交给调用者实现,灵活适配不同需求。总结:回调函数是一种 “反向调用” 设计,
pthread_create
预先留好 “函数指针的坑”,你填自己的routine
逻辑,让线程按你的逻辑跑。核心是解耦框架(pthread
)和业务逻辑(你要线程做的事),让代码更灵活。
线程创建好之后,新线程要被主线程等待,不然就会产生类似僵尸进程的问题,导致内存泄漏
-
我们可以通过pthread_join函数来让主线程进行等待
[^] 参数1是传新线程id,参数2则是获取上面pthread_create参数3的返回值(不关心新线程执行的怎么样,也就是不关心新线程执行的退出结果可以传nullptr,需要获取则要取地址传入一个指针变量才能拿到返回值为void*的变量)
新线程return
主线程接收
打印的结果就是123
我们可能会有一个疑惑,那就是在进程等待时会有异常相关的字段,为啥线程这里的join却没有呢?答:那是因为等待的目标线程如果异常了,整个进程都退出了,包括主线程,所以join异常是没有意义的,压根就看不到;join都是基于线程健康跑完的情况,不需要处理异常信号,异常信号是进程要处理的话题!!
如果我们获取一下新线程的tid,会发现它压根就不是我们查看的线程lwp
因为lwp是轻量级进程的概念,而我们在用户上不要看到这个,因为封装要封装彻底(不然用户本来只需要专注于线程就好了,这样一搞岂不是还需要去了解轻量级进程嘛),我们这里获取到的线程id就不会是lwp!!
-
我们使用pthread_self函数来获取调用了这个函数的线程的id
我们通过这个函数来看看pthread_create返回的线程id是否与新线程通过pthread_self获取到的自己的id相等,进而验证pthread_create返回的线程id就是新线程的id
#include <iostream>
#include <pthread.h>
#include <thread>
#include <unistd.h>
using namespace std;
void FormatId(pthread_t tid)
{printf("新线程通过pthread_self获取到的自己的id为: %ld\n", tid);
}
void *routine(void *args)
{string name = static_cast<const char *>(args);// 获取该线程的id来验证一下在主线程中pthread_create中取得的id是否一致pthread_t tid = pthread_self();FormatId(tid);int cnt = 5;while (cnt){cout << "我是一个新线程, 我的名字是: " << name << endl;cnt--;sleep(1);}return nullptr;
}
void showid(pthread_t id)
{printf("pthread_create返回的线程id为: %ld\n", id);
}
int main()
{pthread_t tid;int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");showid(tid);(void)n;
// 主线程进行等待pthread_join(tid, nullptr);
return 0;
}
通过结果可以看到pthread_create返回的线程id就是新线程的id!!
关于上述代码的一些结论:
-
main函数结束,代表主线程结束,一般也代表着进程结束
-
新线程对应的入口函数运行结束,代表当前线程运行结束
-
给线程传递参数和返回值,可以是任意类型,不一定非得是内置类型,我们自定义类型对象也可以
线程终止问题:
-
线程的入口函数进行return就是线程终止(这种方式用的最多)
-
注意:线程不能用exit()终止,因为exit是终止进程的
-
线程要终止也可用pthread_exit()函数,可以终止调用这个函数的线程
[^]: return res等价于pthread_exit(res)
-
终止线程还可以使用pthread_cancel函数,一般都是由主线程来用这个函数来取消新线程,用此方式终止线程的退出结果是-1【PTHREAD_CANCELED】
问题:如果主线程不想再关心新线程,而是当新线程结束的时候,让它自己进行释放,此时我们要如何做呢?
解决方案:设置新线程为分离状态
技术层面:线程默认是需要被等待的,状态是joinable;如果不想让主线程等待新线程,
想让新线程结束之后,自己退出,设置为分离状态(!joinable or detach)
理解层面:线程分离,可以是主线程分离新线程,也可以是新线程把自己分离
注意:分离的线程依旧在进程的地址空间中,进程的所有资源,被分离的线程依旧可以访问,可以操作,只不过主线程不等待新线程了
分离操作:
-
可以使用pthread_detach函数进行分离
[^] 新线程分离自己可用pthread_detach(pthread_self())
如果线程被设置为分离状态,不需要进行join等待,join会失败
实现一个简单的多线程代码:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <thread>
#include <string.h>
#include <unistd.h>
using namespace std;
// 创建多线程
const int num = 10;
void *routine(void *args)
{string name = static_cast<const char *>(args);delete args;int cnt = 5;while (cnt--){cout << "new线程名字: " << name << endl;sleep(1);}
return nullptr;
}
int main()
{vector<pthread_t> tids;for (int i = 0; i < num; i++){pthread_t tid;// 我们采用下面这种id的做法是有问题不安全的// 因为传的指向id的首地址,各线程看到的是同一个id缓冲区,那么最后线程打出来的id// 都会是最后一次循环时修改覆盖掉前面内容的thread-9// char id[64];// 需要的是,每一次循环,都给对应的线程申请堆空间,这样才能让这一循环中创建的新线程// 独享这块堆空间的起始地址char *id = new char[64];snprintf(id, 64, "thread-%d", i); // 将后面的格式化输出到id缓冲区中int n = pthread_create(&tid, nullptr, routine, id);if (n == 0){tids.push_back(tid);}else{continue;}}
// 主线程才往下走for (int i = 0; i < num; i++){// 一个一个的等待int n = pthread_join(tids[i], nullptr);if (n == 0){cout << "等待新线程成功" << endl;}}
return 0;
}
我们可以自主封装一个线程接口类,具体可见:thread/Thread.hpp
3.线程id及进程地址空间布局
线程的概念是在库中维护的(linux所有的线程都在库中),在库内部就一定会存在多个被创建好的线程,库当然要管理这样线程,管理的方法也还是先描述,再组织
会有struct tcb这样的结构体,当我们调用pthread_create时,这个pthread_create内部就会帮我们在系统当中申请对应的tcb,就如同我们的fopen调用时会在内部申请FILE对象
struct tcb
{//线程应该有的属性,用户需要的线程状态线程id线程独立的栈结构线程栈大小...(而像优先级、时间片、上下文这种用户不需要的与调度有关的属性是被写到内核的lwp->pcb中)
}
[^] 我们上面的tid其实就是线程在库中对应管理块(红框)的起始虚拟地址,当线程return退出后,管理块中的数据并没有被释放,所以得join,传入起始地址用来释放以及通过ret取到管理块中保存的结果
通过上图中每个管理块都有线程栈,我们可以知道,每个线程都必须有自己独立的栈空间(在申请的管理块当中),主线程则用的是地址空间中的栈,新线程用的是自己申请的管理块中的栈,所以说每个线程都必须有自己独立的栈结构
库中创建好管理块把一些数据给线程,直接等该线程执行对应方法就好了
linux 用户级线程 :内核lwp = 1:1
在前面加上__thread的变量会分别在不同线程的线程局部存储位置开辟一份,名字一样,但是底层的虚拟地址不一样了,就可以实现全局变量变成分别新线程的局部变量了
线程的局部存储有什么用:有时我们需要全局变量,但又不想让这个全局变量被其他线程看到时就可以在这个变量前面加上__thread
(但是线程局部存储只能存储内置类型和部分指针)
4.线程栈
独立的上下文:有独立的PCB(内核)+TCP(用户层,pthread库内部)
独立的栈:每个线程都有自己的栈,要么是线程自己的,要么是库中创建进程时mmap申请出来的
虽然 Linux 将线程和进程不加区分的统⼀到了 task_struct ,但是对待其地址空间的stack还是有些区别的。
• 对于 Linux 进程或者说主线程,简单理解就是main函数的栈空间,在fork的时候,实际上就是复制了⽗亲的stack 空间地址,然后写时拷⻉(cow)以及动态增⻓。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程栈是唯⼀可以访问未映射⻚⽽不⼀定会发⽣段错误⸺超出扩充上限才报。
• 然⽽对于主线程⽣成的⼦线程⽽⾔,其 stack将不再是向下⽣⻓的,⽽是事先固定下来的。线程栈⼀般是调⽤glibc/uclibc等的pthread 库接⼝ pthread_create创建的线程,在⽂件映射区(或称之为共享区)。其中使⽤mmap 系统调⽤,这个可以从 glibc的nptl/allocatestack.c 中的 allocate_stack函数中看到:
mem = mmap (NULL, size, prot,MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);此调⽤中的 size 参数的获取很是复杂,你可以⼿⼯传⼊stack的⼤⼩,也可以使⽤默认的,⼀般⽽⾔就是默认的 8M 。这些都不重要,重要的是,这种stack不能动态增⻓,⼀旦⽤尽就没了,这是和⽣成进程的fork不同的地⽅。在glibc中通过mmap得到了stack之后,底层将调⽤ sys_clone系统调⽤:
因此,对于⼦线程的 stack ,它其实是在进程的地址空间中map出来的⼀块内存区域,原则上是线程私有的,但是同⼀个进程的所有线程⽣成的时候,是会浅拷⻉⽣成者的 task_struct的很多字段,如果愿意,其它线程也还是可以访问到的,于是⼀定要注意