个人主页:平凡的小苏
学习格言:命运给你一个低的起点,是想看你精彩的翻盘,而不是让你自甘堕落,脚下的路虽然难走,但我还能走,比起向阳而生,我更想尝试逆风翻盘。
C++专栏:Linux内功修炼
家人们更新不易,你们的点赞和⭐关注⭐真的对我真重要,各位路 过的友友麻烦多多点赞关注。欢迎你们的私信提问,感谢你们的转发! 关注我,关注我,关注我,你们将会看到更多的优质内容!!
在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
一切进程至少都有一个执行线程。
线程在进程内部运行,本质是在进程地址空间内运行。
在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更轻量化。所以Linux下的进程称之为轻量级进程。
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
根据我们先前的了解,一个进程的创建实际上伴随着其进程控制块(task_struct)、进程地址空间(mm_struct)以及页表的创建,虚拟地址和物理地址就是通过页表建立映射的。
如果我们在创建“进程”时,只创建task_struct,并要求创建出来的task_struct和父task_struct共享进程地址空间和页表,那么创建的结果就是下面这样的:
现在创建的进程不再给你独立分配地址空间和页表,而是都指向同一块地址空间,共享同一块页表。所以这四个task_struct看到的资源都是一样的,我们后续可以通过某种方式把代码区拆分成4块,让这四个task_struct执行不同的代码区域,上述的区域(数据区,堆区,栈区)也是类似处理方式。换言之,我们后续创建的3个task_struct都各自有自己的一小份代码和数据,我们把这样的一份task_struct称之为线程。
上述谈的线程仅仅是在Linux下的实现原理,不同平台对线程的管理可能是不一样的。Linux其实并没有真正的对线程创建对应的数据结构:
线程本身是在进程内部运行的,操作系统中存在大量的进程,一个进程内又存在一个或多个线程,因此线程的数量一定比进程的数量多(线程 : 进程 一定是n : 1),当线程的数量足够多的时候,很明显线程的执行粒度要比进程更细。
对于这么多的线程我们OS需要对其做管理(先描述,再组织),在大部分的OS中,线程都有一个tcb。如果我们的系统实现的是真线程,比如说windows平台,它就要分别对进程和线程设计各自的描述的数据块(结构体),并且很多线程在一个进程内部,所以还要维护线程tcb和进程pcb之间的关系。所以这样写出的代码,其tcb和pcb两个数据结构之间的耦合度非常复杂。设计tcb和pcb的人认为这样的进程和线程在执行流层面上是不一样的。但是Linux不这样想:在概念上没有进程和线程的区分,只有一个叫做执行流。Linux的线程是用进程PCB模拟的。所以在Linux当中,其PCB和TCB是一回事!!!
Linux的线程用进程PCB模拟的好处很明显:
总览如下:
看此图对于页表的注释,来分析下面的一份代码:
char* msg = "hello world";
*msg = 'H';
问:上述代码对吗?
字符串常量区在代码区和已初始化数据区之间的,如果它不可被修改,那它是如何加载到物理内存呢?或者说是谁保证它不可被修改的?
问:有了线程的引入,该如何重新理解之前的进程?
曾经我们理解的进程 = 内核数据结构 + 进程对应的代码和数据,现在的进程,站在内核角度上看就是:承担分配系统资源的基本实体(进程的基座属性)。所有进程最大的意义是向系统申请资源的基本单位。
我们之前接触到的进程内部都只有一个task_struct,也就是该进程内部只有一个执行流,即单执行流进程
而内部可以有多个执行流的进程我们称之为多执行流进程
我们以32位平台为例,在32位平台下一共有232个地址,地址空间的单位就是232 * 1字节 = 4GB。此时如果做地址之间的映射,每个虚拟地址都要有对应的物理地址。如果页表只有一张,那么需要多少条目(页表项)呢?答案是232个条目,即这张表一共有232个映射表项。
每一个表项中除了要有虚拟地址和与其映射的物理地址以外,实际还需要有一些权限相关的信息,比如我们所说的用户级页表和内核级页表,实际就是通过权限进行区分的。
注意:
所以我们实际的页表并不是这样子的,我们的页表是多级页表,在32位平台下是二级页表。
我们的cpu通过地址空间访问物理内存的时,cpu读取指定的数据和代码然后根据指定的地址返回物理内存的时候,cpu出来的地址是虚拟地址,我们的进程地址空间是2^32个,我们的虚拟地址是32位。而虚拟地址在被转化的过程中,不是直接转化的!而是拆分成了10 + 10 + 12!
32位平台下,虚拟地址映射转化的过程如下:
选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表。
再选择虚拟地址的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址。
最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据。
物理内存在划分的时候是按4KB位单位进行划分的(这里的4KB叫做页框),可执行程序按照虚拟地址空间编译,也划分号了4KB(这里的4KB叫做页帧)。我们的文件系统在和物理内存进行IO的时候,其基本单位是块,一般是4KB。
虚拟地址映射过程图示如下:
如果页表只有1张,要占2^32 / 2^12 = 2^20条目,即使一个条目10字节,页表最大也就10M到20M。如果把整个页旋转一下,把页目录放上面,就相当于一颗多叉树。
总结上述页表这样设计的好处:
- 进程虚拟地址管理和内存管理,通过页表 + page进行了解耦
- 页表分离了,可以实现页表的按需获取,没有用到的就不创建
- 分页机制 + 按需创建页表 = 节省空间
创建一个新线程的代价要比创建一个新进程小得多
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
线程占用的资源要比进程少很多
能充分利用多处理器的可并行数量
在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
注意:
线程共享进程数据,但也拥有自己的一部分数据:
因为是在同一个地址空间,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:
如果定义一个函数,在各线程中都可以调用。
如果定义一个全局变量,在各线程中都可以访问到。
除此之外,各线程还共享以下进程资源和环境:
原生线程库pthread
在Linux中,站在内核角度没有真正意义上线程相关的接口,但是站在用户角度,当用户想创建一个线程时更期望使用thread_create这样类似的接口,而不是vfork函数,因此系统为用户层提供了原生线程库pthread。
原生线程库实际就是对轻量级进程的系统调用进行了封装,在用户层模拟实现了一套线程相关的接口。
因此对于我们来讲,在Linux下学习线程实际上就是学习在用户层模拟实现的这一套接口,而并非操作系统的接口。
pthread线程库是应用层的原生线程库:
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分POSIX函数会这样做),而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小。
创建线程的函数叫做pthread_create,其函数原型如下:
#include
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数说明:
返回值说明
注意
常见获取线程ID的方式有两种:
pthread_self函数的函数原型如下:
pthread_t pthread_self(void);
首先需要明确的是,一个线程被创建出来,这个线程就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。所以线程需要被等待,如果不等待会产生类似于“僵尸进程”的问题,也就是内存泄漏。等待线程的函数叫做pthread_join,函数原型如下:
#include
int pthread_join(pthread_t thread, void **retval);
参数说明:
thread:被等待线程的ID。
retval:线程退出时的退出码信息。
返回值说明:
线程等待成功返回0,失败返回错误码。
如果需要只终止某个线程而不是终止整个进程,可以有三种方法:
方法一(从线程函数return)
方法二(pthread_exit)
#include
void pthread_exit(void *retval);
参数说明:
注意:
例如,在下面代码中,我们使用pthread_exit函数终止线程,并将线程的退出码设置为1111:
#include
#include
#include
using namespace std;
static void printTid(const char *name, const pthread_t &tid)
{
printf("%s 正在运行, thread id: 0x%x\n", name, tid);
}
void *startRoutine(void *args)
{
const char *name = static_cast<const char *>(args);
int cnt = 5;
while (true)
{
printTid(name, pthread_self());
sleep(1);
if (!(cnt--))
{
break;
}
}
cout << "线程退出啦...." << endl;
//1、线程退出方式1: 从线程函数直接return
/*return (void *)111;*/
//2、线程退出方式2: pthread_exit
pthread_exit((void*)1111);
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void *)"thread1");
(void)n;
void *ret = nullptr;
pthread_join(tid, &ret);
cout << "main thread join success, *ret: " << (long long)ret << endl;
sleep(10);
while (true)
{
printTid("main thread", pthread_self());
sleep(1);
}
return 0;
}
这段代码我们也能看出使用pthread_exit只能退出当前子线程,不会影响其它线程。
问:为何终止线程要用pthread_exit, exit 不行吗?
看如下的代码:
#include
#include
#include
#include
#include
#include
using namespace std;
__thread int global_value = 100;
void *startRoutine(void *args)
{
while (true)
{
cout << "thread" << pthread_self() << " global_value: " << global_value
<< " Inc: " << global_value++ << "lwp: " << syscall(SYS_gettid) << endl;
sleep(1);
break;
}
exit(1);
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
总结:
方法三:(pthread_cancel)
#include
int pthread_cancel(pthread_t thread);
参数说明:
返回值说明:
线程是可以取消自己的,取消成功的线程的退出码一般是-1。例如在下面的代码中,我们让线程执行一次打印操作后将自己取消:
#include
#include
#include
using namespace std;
static void printTid(const char *name, const pthread_t &tid)
{
printf("%s 正在运行, thread id: 0x%x\n", name, tid);
}
void *startRoutine(void *args)
{
const char *name = static_cast<const char *>(args);
int cnt = 5;
while (true)
{
printTid(name, pthread_self());
sleep(1);
if (!(cnt--))
{
// break;
}
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void *)"thread1");
(void)n;
sleep(3);//代表main thread对应的工作
cout << "new thread been canceled" << endl;
pthread_cancel(tid);
void *ret = nullptr;
pthread_join(tid, &ret);
cout << "main thread join success, *ret: " << (long long)ret << endl;
sleep(10);
while (true)
{
printTid("main thread", pthread_self());
sleep(1);
}
return 0;
}
为什么退出的结果是-1呢?
上述我们做的测试是让main thread主线程去取消新线程new thread,不推荐反过来。这里就不做测试了。
pthread_t实际上就是地址。
线程的独立栈结构:
我们使用的线程库,是用户级线程库:pthread。是因为Linux没有真线程,没有办法提供真的线程调用接口,只能提供创建子进程、共享地址空间的调用接口。但是进程的代码、数据……怎么划分这些都是由线程库自己维护的。注意:此pthread库是动态库。
问:pthread_t究竟是什么?
既然我们已经知道此动态库会被加载到共享区,那么我们把此共享区的libpthread.so动态库放大来讨论。线程的全部实现,并没有全部体现在OS内,而是OS提供执行流,具体的线程结构由库来进行管理。如下:
库可以创建多个线程,需要对这些线程进行管理(先描述,再组织)。库里头通过类似struct thread_info的结构体(注意里头是有私有栈的)来进行管理:
struct thread_info
{
pthread_t tid;
void *stack; // 私有栈
...
}
当你在用户层每创建一个线程时,在库里头就会创建一个线程控制块struct thread_info(描述线程的属性)。给创建线程的用户返回的是该结构体的起始虚拟地址。所以我们的pthread_t实际上就是用户级线程的控制结构体的起始地址!!!。
既然每一个线程都有struct thread_info结构体,而此结构体内部又有私有栈,所以结论如下:
我们的线程除了保存临时数据时可以有自己的线程栈,我们的pthread给我们了一种能力,如果定义了一个全局变量(默认所有线程共享),但是你想让每个线程各自私有,那么我们就可以使用线程局部存储。
如下我们创建了3个线程,创建一个全局变量,默认情况下此全局变量所有线程共享,现在我们来打印此全局变量以及地址来观察现象:
#include
#include
#include
using namespace std;
int global_value = 100;
void *startRoutine(void *args)
{
while (true)
{
cout << "thread" << pthread_self() << " global_value: " << global_value
<< " &global_value: " << &global_value << " Inc: " << global_value++ << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
正常情况下,我们观察到着三个线程打印的全局变量地址应该都是一样的,且打印的变量是在累加的,这是正常的,因为共享全局变量,我的修改别人也能拿到。
为了让此全局变量独属于各个线程所私有,我们只需要给全局变量前假设__thread即可,加了这个__thread就会默认把这个global_value再拷一份给每一个进程。
__thread int global_value = 100;
代码如下:
#include
#include
#include
using namespace std;
__thread int global_value = 100;
void *startRoutine(void *args)
{
while (true)
{
cout << "thread" << pthread_self() << " global_value: " << global_value
<< " &global_value: " << &global_value << " Inc: " << global_value++ << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
pthread_join(tid3, nullptr);
return 0;
}
如下可以看到,创建的3个线程,每个线程的全局变量的地址都是不一样的,修改变量时,互相之间没有影响,各自独立。
分离线程的函数叫做pthread_detach,pthread_detach函数的函数原型如下:
#include
int pthread_detach(pthread_t thread);
参数说明:
返回值说明:
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。我们编写如下的代码进行验证:
#include
#include
#include
#include
#include
#include
using namespace std;
__thread int global_value = 100;
void *startRoutine(void *args)
{
pthread_detach(pthread_self());
cout << "线程分离..." << endl;
while (true)
{
cout << "thread" << pthread_self() << " global_value: " << global_value
<< " Inc: " << global_value++ << "lwp: " << syscall(SYS_gettid) << endl;
sleep(1);
}
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_t tid3;
pthread_create(&tid1, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 1");
pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 1");
int n = pthread_join(tid1, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid2, nullptr);
cout << n << ":" << strerror(n) << endl;
n = pthread_join(tid3, nullptr);
cout << n << ":" << strerror(n) << endl;
return 0;
}
不是说好一个线程不能既是joinable又是分离的吗,下面我们对上述代码进行一次小改动,仅仅多了一个sleep(1):
为什么我sleep(1)后才符合我们的预期呢?( 一个线程不能既是joinable又是分离的)。有sleep之后join就会失败,没有sleep,join就会成功,那么哪个才是正确的呢?
总结分离线程: