目录
一、前言
二、线程的知识细节补充
🔥线程私有资源🔥
🔥线程共享资源🔥
🔥原生线程库🔥
三、线程控制接口
💧 线程的创建💧
💧 多线程创建💧
💧 线程等待💧
💧 线程终止💧
💧 多线程编程实战💧
💧其他接口💧
四、深度理解线程
🍇理解线程ID
🥝理解线程独立栈
🍋理解线程局部存储
五、共勉
一、前言
【线程】是进程内部的一个执行流,作为
CPU
运行的基本单位,对于线程的合理控制与任务的执行效率息息相关,因此掌握线程基本操作(线程控制)是很有必要的
二、线程的知识细节补充
在正式介绍线程控制相关接口前,需要先补充一波线程相关知识
🔥线程私有资源🔥
在 【线程的深度解析中】 中我们得出了一个结论:
Linux
中没有真线程,只有复用PCB
设计思想的TCB
结构
- 在 Linux 中,认为 【PCB】 与 【TCB】 的共同点太多了,于是直接复用了 PCB 的设计思想和调度策略,在进行 【线程管理】 时,完全可以复用 【进程管理】 的解决方案(代码和结构),这可以大大减少系统调度时的开销,做到 小而美,因此 Linux 中实际是没有真正的 线程 概念的,有的只是复用 PCB 设计思想的 TCB
- 在这种设计思想下,【线程】 注定不会过于庞大,因此 Linux 中的 线程 又可以称为 轻量级进程(LWP),轻量级进程 足够简单,且 易于维护、效率更高、安全性更强,可以使得 Linux 系统不间断的运行程序,不会轻易 崩溃
因此
Linux
中的线程本质上就是 轻量级进程(LWP
),一个进程内的【多个线程】看到的是同一个进程地址空间,所以所有的线程可能会共享进程的大部分资源
但是如果多个执行流(多个线程)都使用同一份资源,如何确保自己的相对独立性呢?
- 相对独立性:线程各司其职,不至于乱成一锅粥
- 显然,多线程虽然共同 “生活” 在一个进程中,但也需要有自己的 “隐私”,而这正是 线程私有资源
线程私有资源:
- 线程 ID:内核观点中的 LWP
- 一组寄存器: 线程切换时,当前线程的上下文数据需要被保存
- 线程独立栈: 线程在执行函数时,需要创建临时变量
- 错误码 errno: 线程因错误终止时,需要告知父进程
- 信号屏蔽字: 不同线程对于信号的屏蔽需求不同
- 调度优先级: 线程也是要被调度的,需要根据优先级进行合理调度
其中,线程 最重要 的资源是 一组寄存器(体现切换特性)和独立栈(体现临时运行特性)
🔥线程共享资源🔥
除了上述提到的 线程私有资源 外,【多线程】 还共享着进程中的部分资源
- 共享的定义:不需要太多的额外成本,就可以实现随时访问资源
- 基于 多线程看到的是同一块进程地址空间,理论上 凡是在进程地址空间中出现的资源,多线程都是可以看到的
- 但实际上为了确保线程调度、运行时的独立性,只能共享部分资源
这也就是线程中的栈区称作 “独立栈” 的原因:某块栈空间属于某个线程,其他线程是可以访问的,为了确保独立性,并不会这样做
在
Linux
中,多线程共享资源如下
- 共享区、全局数据区、字符常量区、代码区: 常规资源共享区
- 文件描述符表: 进行 IO 操作时,无需再次打开文件
- 每种信号的处理方式: 多线程共同构成一个整体,信号的处理动作必须统一
- 当前工作目录: 即使是多线程,也是位于同一工作目录下
- 用户 ID 和 组 ID: 进程属于某个组中的某个用户,多线程也是如此
🔥原生线程库🔥
在之前编译多线程相关代码时,我们必须带上一个选项:
-lpthread
,否则就无法使用多线程相关接口
- 带上这个选项的目的很简单:使用
pthread
原生线程库
接下来对 原生线程库 进行一个系统性的理解
- 首先,在
Linux
中是没有真正意义上的线程,有的只是通过进程模拟实现的线程(LWP
) - 站在操作系统角度:并不会提供对线程控制的相关接口,最多提供轻量级进程操作的相关接口
- 但是对于用户来说,只认识线程,并不清楚轻量级进程
所以为了使用户能愉快的对线程进行操作,就需要对系统提供的轻量级进程操作相关接口进行封装:对下封装轻量级进程操作相关接口,对上给用户提供线程控制的相关接口
在
Linux
中,封装轻量级进程操作相关接口的库称为pthread
库,即 原生线程库,这个库文件是所有Linux
系统都必须预载的,用户使用多线程控制相关接口时,只需要指明使用-lpthread
库,即可正常使用多线程控制相关接口
三、线程控制接口
有了前面知识的补充之后,接下来正式进入线程控制接口的学习
💧 线程的创建💧
要想控制线程,得先创建线程,对于 原生线程库 来说,创建线程使用的是
pthread_create
这个接口
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
- 先来认识一下函数中涉及的参数
参数1:
pthread_t*
线程ID
,用于标识线程,其实这玩意本质上就是一个unsigned long int
类型
- 注:
pthread_t*
表明这是一个输出型参数,旨在创建线程后,获取新线程ID
参数2:
const pthread_attr_t*
用于设置线程的属性,比如优先级、状态、私有栈大小
- 这个参数一般不考虑,直接传递
nullptr
使用默认设置即可
参数3:
void *(*start_routine) (void *)
这是一个很重要的参数,它是一个 返回值为void*
参数也为void*
的函数指针,线程启动时,会自动回调此函数
- 指向线程函数的指针,即新线程将执行的函数。
参数4:
void*
显然,这个类型与回调函数中的参数类型匹配上了,而这正是线程运行时,传递给回调函数的参数
- 返回值
int
:创建成功返回0
,失败返回error number
注意事项
- 线程函数必须有
void*
类型的参数,并返回void*
类型的结果。 - 需要包含头文件
#include <pthread.h>
。 - 在编译时需要链接
pthread
库,例如使用-lpthread
选项。
明白创建线程函数的各个参数后,就可以尝试创建一个线程了
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;// 线程的回调函数
void* threadRun(void *arg)
{while(true){cout << "我是次线程,我正在运行..." << endl;sleep(1);}return nullptr;
}int main()
{pthread_t t; // 线程ID // pthread_create 创建线程 // pthread_create(&线程ID , 线程属性(通常为nullptr), 线程启动调用的函数 , 线程返回值(没设置就是nullptr))pthread_create(&t, nullptr, threadRun, nullptr); while(true){cout << "我是主线程 " << " 我创建了一个次线程 " << t << endl;sleep(1);}return 0;
}
- 代码运行成功 ,同时我们还可以检测两个线程
while :; do ps -aL | head -1 && ps -aL | grep thread ; echo "-----------------------------------"; sleep 1 ; done
.如何验证 原生线程库 存在?
现在我们已经得到了一个链接 原生线程库 的可执行程序,可以通过 ldd 可执行程序
查看库的链接情况
ldd thread
- 足以证明原生线程库确确实实的存在于我们的系统中
💧 多线程创建💧
根据上面演示的线程创建,我们再来看看多线程的创建
#include <iostream>
#include <unistd.h>
#include <pthread.h> // 线程库using namespace std;#define NUM 5void* threadRun(void *name)
{while(true){cout << "我是次线程 " << (char*)name << endl;sleep(1);}return nullptr;
}int main()
{pthread_t pt[NUM]; // 将线程ID 放入数组中,为创建多个线程做准备for(int i = 0; i < NUM; i++){// 注册新线程的信息char name[64];// 将"thread-%d" 转换为字符串 放在 name中snprintf(name, sizeof(name), "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name); // 创建新线程,并给予其name名称}while(true){cout << "我是主线程,我正在运行" << endl;sleep(1);}return 0;
}
- 细节:传递
pthread_create
的参数1时,可以通过 起始地址+偏移量 的方式进行传递,传递的就是pthread_t*
- 预期结果:打印
thread-1
、thread-2
、thread-3
…
- 实际结果:确实有五个次线程在运行,但打印的结果全是
thread-5
- 原因:
char name[64]
属于主线程中栈区之上的变量,多个线程实际指向的是同一块空间,最后一次覆盖后,所有线程都打印thread-5
这是由于多线程共享同一块区域引发的问题,解决方法就是在堆区动态匹配空间,使不同的线程读取不同的空间,这样就能确保各自信息的独立性
#include <iostream>
#include <unistd.h>
#include <pthread.h> // 线程库using namespace std;#define NUM 5void* threadRun(void *name)
{while(true){cout << "我是次线程 " << (char*)name << endl;sleep(1);}delete[] (char*)name;return nullptr;
}int main()
{pthread_t pt[NUM]; // 将线程ID 放入数组中,为创建多个线程做准备for(int i = 0; i < NUM; i++){// 注册新线程的信息char* name = new char[64];// 将"thread-%d" 转换为字符串 放在 name中snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name); // 创建新线程,并给予其name名称}while(true){cout << "我是主线程,我正在运行" << endl;sleep(1);}return 0;
}
- 显然,线程每次的运行顺序取决于调度器
在上面的程序中,主线程也是在死循环式运行,假若主线程等待
3
秒后,再return
, 会发生什么呢?
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 5void* threadRun(void *name)
{while(true){cout << "我是次线程 " << (char*)name << endl;sleep(1);}delete[] (char*)name;return nullptr;
}int main()
{pthread_t pt[NUM];for(int i = 0; i < NUM; i++){// 注册新线程的信息char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}// 等待 3 秒后 returnsleep(3);return 0;
}
- 结果:程序运行
3
秒后,主线程退出,同时其他次线程也被强制结束了 - 这是因为 主线程结束了,整个进程的资源都得被释放,次线程自然也就无法继续运行了
换句话说,次线程由主线程创建,主线程就得对他们负责,必须等待他们运行结束,类似于父子进程间的等待机制;如果不等待,就会引发僵尸进程问题,不过线程这里没有僵尸线程的概念,直接影响就是次线程也全部退出了
💧 线程等待💧
主线程需要等待次线程,在 原生线程库 中刚好存在这样一个接口
pthread_join
,用于等待次线程运行结束
#include <pthread.h>int pthread_join(pthread_t thread, void **retval);
- 参数1:
pthread_t
待等待的线程ID
,本质上就是一个无符号长整型类型;这里传递是数值,并非地址 - 参数2:
void**
这是一个输出型参数,用于获取次线程的退出结果,如果不关心,可以传递nullptr
- 返回值:成功返回
0
,失败返回error number
函数原型很简单,使用也很简单,我们可以直接在主线程中调用并等待所有次线程运行结束
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h> // 线程库using namespace std;#define NUM 5void* threadRun(void *name)
{int count = 5;while(count>0){cout << "我是次线程 " << (char*)name <<" "<< "pid :"<<getpid()<<endl;sleep(1);count--;}delete[] (char*)name;return (void*)666;
}int main()
{pthread_t tid; // 创建线程ID// 注册新线程的信息char* name = new char[64];// 将"my name is xas" 转换为字符串 放在 name中snprintf(name, 64, "my name is xas");pthread_create(&tid, nullptr, threadRun, name); // 创建新线程,并给予其name名称cout << "我是主线程 " << "pid :"<<getpid()<<endl;void* ret = nullptr; // 获取线程返回值int n = pthread_join(tid , &ret);cout<<"main thread quit , n = "<< n <<" "<< "main thread get a ret : "<<(long long)ret<<endl;return 0;
}
💧 线程终止💧
线程可以被创建并运行,也可以被终止,线程终止方式有很多种
- 比如 等待线程回调函数执行结束,次线程运行五秒后就结束了,然后被主线程中的
pthread_join
等待成功,次线程使命完成
void* threadRun(void *name)
{int count = 5;while(count>0){cout << "我是次线程 " << (char*)name <<" "<< "pid :"<<getpid()<<endl;sleep(1);count--;}delete[] (char*)name;return (void*)666;
}
- 其实 原生线程库 中有专门终止线程运行的接口
pthread_exit
,专门用来细粒度地终止线程,谁调用就终止谁,不会误伤其他线程
#include <pthread.h>void pthread_exit(void *retval);
- 仅有一个参数
void*
:用于传递线程退出时的信息
这个参数名叫 retval,pthread_join 中的参数2也叫 retval,两者有什么不可告人的秘密吗?
- 答案是这俩其实本质上是同一个东西,pthread_join 中的 void **retval 是一个输出型参数,可以把一个 void * 指针的地址传递给 pthread_join 函数,当线程调用 pthread_exit 退出时,可以根据此地址对 retval 赋值,从而起到将退出信息返回给主线程的作用
#include <iostream>
#include <unistd.h>
#include <pthread.h>using namespace std;#define NUM 3void* threadRun(void *name)
{cout << "我是次线程 " << (char*)name << endl;sleep(1);delete[] (char*)name;pthread_exit((void*)"EXIT");// 直接return "EXIT" 也是可以的// return (void*)"EXIT";
}int main()
{pthread_t pt[NUM];for(int i = 0; i < NUM; i++){// 注册新线程的信息char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(pt + i, nullptr, threadRun, name);}// 等待次线程运行结束void *retval = nullptr;for(int i = 0; i < NUM; i++){int ret = pthread_join(pt[i], &retval);if(ret != 0){cerr << "等待线程 " << pt[i] << " 失败!" << endl;}cout << "线程 " << pt[i] << " 等待成功,退出信息是 " << (const char*)retval << endl;}cout << "所有线程都退出了" << endl;return 0;
}
💧 多线程编程实战💧
无论是
pthread_create
还是pthread_join
,他们的参数都有一个共同点:包含了一个void*
类型的参数,这就是意味着我们可以给线程传递对象,并借此进行某种任务处理
比如我们先创建一个包含一下信息的线程信息类,用于计算 [0, N]
的累加和
- 线程名字(包含
ID
) - 线程编号
- 线程创建时间
- 待计算的值
N
- 计算结果
- 状态
为了方便访问成员,权限设为
public
// 线程信息类的状态
enum class Status
{OK = 0,ERROR
};// 线程信息类
class ThreadData
{
public:ThreadData(const string &name, int id, int n):_name(name),_id(id),_createTime(time(nullptr)),_n(n),_result(0),_status(Status::OK){}public:string _name; // 线程名称int _id; // 线程编号time_t _createTime;int _n; // 累加到几int _result; // 运算相加的结果Status _status; //线程状态
};
- 此时就可以编写 回调方法 中的业务逻辑了
void* threadRun(void *arg)
{ThreadData *td = static_cast<ThreadData*>(arg);// 业务处理for(int i = 0; i <= td->_n; i++)td->_result += i;// 如果业务处理过程中发现异常行为,可以设置 _status 为 ERRORcout << "线程 " << td->_name << " ID " << td->_id << " CreateTime " << td->_createTime << " done..." << endl;pthread_exit((void*)td);// 也可以直接 return // return td;
}
主线程在创建线程及等待线程时,就可以使用
ThreadData
对象了,后续涉及业务修改时,也只需要修改类及回调方法即可,无需再更改创建及等待逻辑,有效做到了 解耦
int main()
{pthread_t pt[NUM];for(int i = 0; i < NUM; i++){// 注册新线程的信息char name[64];snprintf(name, sizeof(name), "thread-%d", i + 1);// 创建对象ThreadData *td = new ThreadData(name, i, 100 * (10 + i));pthread_create(pt + i, nullptr, threadRun, td);sleep(1); // 尽量拉开创建时间}// 等待次线程运行结束void *retval = nullptr;for(int i = 0; i < NUM; i++){int ret = pthread_join(pt[i], &retval);if(ret != 0)cerr << "等待线程 " << pt[i] << " 失败!" << endl;ThreadData *td = static_cast<ThreadData*>(retval);if(td->_status == Status::OK)cout << "线程 " << pt[i] << " 计算 [0, " << td->_n << "] 的累加和结果为 " << td->_result << endl;delete td;}cout << "所有线程都退出了" << endl;return 0;
}
- 程序可以正常运行,各个线程也都能正常计算出结果;这里只是简单计算累加和,线程还可以用于其他场景:网络传输、密集型计算、多路
IO
等,无非就是修改线程的业务逻辑
结论:多线程可以传递对象指针,自由进行任务处理
💧其他接口💧
与多线程相关的还有一批其他接口,比较简单,就放在一起介绍了
关闭线程
线程可以被创建,自然也可以被关闭,可以使用
pthread_cancel
关闭已经创建并运行中的线程
#include <pthread.h>int pthread_cancel(pthread_t thread);
- 参数1:
pthread_t
被关闭的线程ID
- 返回值:成功返回
0
,失败返回一个非零的error number
这里可以直接模拟关闭线程的场景
#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>using namespace std;void *threadRun(void *arg)
{// 将线程名 类型强制转换const char *ps = static_cast<const char*>(arg);while(true){cout << "线程 " << ps << " 正在运行" << endl;sleep(1);}// 终止线程,并返回10pthread_exit((void*)10);
}int main()
{pthread_t t; // 创建线程 ID// 创建线程 (void*)"Hello Thread" -- 为线程名pthread_create(&t, nullptr, threadRun, (void*)"Hello Thread");// 3秒后关闭线程sleep(3);// 关闭线程pthread_cancel(t);void *retval = nullptr;pthread_join(t, &retval);// 细节:使用 int64_t 而非 uint64_tcout << "线程 " << t << " 已退出,退出信息为 " << (int64_t)retval << endl;return 0;
}
- 程序运行
3
秒后,可以看到退出信息为-1
,与我们预设的10
不相符 - 原因很简单:只要是被
pthread_cancel
关闭的线程,退出信息统一为PTHREAD_CANCELED
即-1
- 这也就解释了为什么要强转为
ingt64_t
,因为无符号的-1
非常大,不太好看
获取线程ID
线程
ID
是线程的唯一标识符,可以通过pthread_self
获取当前线程的ID
#include <pthread.h>pthread_t pthread_self(void);
- 返回值:当前线程的
ID
#include <iostream>
#include <string>
#include <ctime>
#include <unistd.h>
#include <pthread.h>using namespace std;void *threadRun(void *arg)
{// 强制类型转换const char* ps = static_cast<const char*>(arg);cout <<"新线程"<<ps<<" "<< "当前次线程的ID为 " << pthread_self() << endl;return nullptr;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, (void*)"Hello thread");pthread_join(t, nullptr);cout << "创建的次线程ID为 " << t << endl;return 0;
}
- 可以看到结果都是一样的
四、深度理解线程
🍇理解线程ID
在见识过 原生线程库 提供的一批便利接口后,不由得感叹库的强大,如此强大的库究竟是如何工作的呢?
- 原生线程库本质上也是一个文件,是一个存储在
/lib
目录下的动态库,要想使用这个库,就得在编译时带上-lpthread
指明使用动态库
程序运行时,原生线程库 需要从 磁盘 加载至 内存 中,再通过 进程地址空间 映射至 共享区 中供线程使用
- 由于用户并不会直接使用 轻量级进程 的接口,于是 需要借助第三方库进行封装,类似于用户可能不了解系统提供的 文件接口,从而使用
C
语言 封装的FILE
库一样
- 对于 原生线程库 来说,线程不止一个,因此遵循 先描述,再组织 原则,在线程库中创建
TCB
结构(类似于PCB
),其中存储 线程 的各种信息,比如 线程独立栈 信息
- 在内存中,整个 线程库 就像一个 “数组”,其中的一块块空间聚合排布
TCB
信息,而 每个TCB
的起始地址就表示当前线程的ID
,地址是唯一的,因此线程ID
也是唯一的
因此,我们之前打印
pthread_t
类型的 线程ID
时,实际打印的是地址,不过是以 十进制 显示的,可以通过函数将地址转化为使用 十六进制 显示
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%x", t);return id;
}void *threadRun(void *arg)
{cout << "我是[次线程],我的ID是 " << toHex(pthread_self()) << endl;return (void*)0;
}int main()
{pthread_t t;pthread_create(&t, nullptr, threadRun, nullptr);pthread_join(t, nullptr);cout << "我是[主线程],我的ID是 " << toHex(pthread_self()) << endl;return 0;
}
线程
ID
确实能转化为地址(虚拟进程地址空间上的地址)
🥝理解线程独立栈
线程 之间存在 独立栈,可以保证彼此之前执行任务时不会相互干扰,可以通过代码证明
- 多个线程使用同一个入口函数,并打印其中临时变量的地址
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%lx", t);return id;
}void *threadRun(void *arg)
{int tmp = 0;cout << "thread " << toHex(pthread_self()) << " &tmp: " << &tmp << endl;return (void*)0;
}int main()
{pthread_t t[5];for(int i = 0; i < 5; i++){pthread_create(t + i, nullptr, threadRun, nullptr);sleep(1);}for(int i = 0; i < 5; i++)pthread_join(t[i], nullptr);return 0;
}
- 可以看到五个线程打印 “同一个” 临时变量的地址并不相同,足以证明 线程独立栈 的存在
注意:
- 所有线程都要有自己独立的栈结构(独立栈),主线程中用的是进程系统栈,次线程用的是库中提供的栈
- 多个线程调用同一个入口函数(回调方法),其中的局部变量地址一定不一样,因为存储在线程独立栈中
🍋理解线程局部存储
线程 之间共享 全局变量,对 全局变量 进行操作时,会影响其他线程
#include <iostream>
#include <string>
#include <unistd.h>
#include <pthread.h>using namespace std;int g_val = 100;string toHex(pthread_t t)
{char id[64];snprintf(id, sizeof(id), "0x%lx", t);return id;
}void *threadRun(void *arg)
{cout << "thread: " << toHex(pthread_self()) << " g_val: " << ++g_val << " &g_val: " << &g_val << endl;return (void*)0;
}int main()
{pthread_t t[3];for(int i = 0; i < 3; i++){pthread_create(t + i, nullptr, threadRun, nullptr);sleep(1);}for(int i = 0; i < 3; i++)pthread_join(t[i], nullptr);return 0;
}
- 在三个线程的影响下,
g_val
最终变成了103
如何让全局变量私有化呢?即每个线程看到的全局变量不同
- 可以给全局变量加
__thread
修饰,修饰之后,全局变量不再存储至全局数据区,而且存储至线程的 局部存储区中
__thread int g_val = 100;
- 结果:修饰之后,每个线程确实看到了不同的 “全局变量”
- 特点:此时的 “全局变量” 的地址变大了
“全局变量” 地址变大是因为此时它不再存储在 全局数据区 中,而且存储在线程的 局部存储区 中,线程的局部存储区位于 共享区,并且 共享区 的地址天然大于 全局数据区
注意: 局部存储区位于共享区中,可以通过 __thread
修饰来改变变量的存储位置
五、共勉
以下就是我对【Linux系统编程】线程控制的深度解析 的理解,如果有不懂和发现问题的小伙伴,请在评论区说出来哦,同时我还会继续更新【Linux系统编程】,请持续关注我哦!!!