在说线程之前,我们需要铺垫一些背景知识:
1.重谈地址空间
实际上,物理内存不是一整块,而是被划分为一份份4KB空间,OS进行内存管理,不是以字节为单位,而是以内存块为单位的,默认大小是4KB,4KB是主流Linux操作系统用的大小。实际上,我们之前学过,系统和磁盘文件进行IO的基本单位是4KB,也就是8个扇区,所有计算机中的巧合都是被精心设计过的。在程序被编译完成后,是有地址的,并且以数据块4KB的大小存好了。所谓程序加载,就是把磁盘上4KB的数据块加载到4KB的内存块上。我们把4KB的内存块叫做页框,4KB的数据块叫做页帧, OS对内存的管理工作,基本单位是4KB!
之前父子进程对数据进行修改时会发生写时拷贝,而写时拷贝的基本单位也是4KB,为什么修改很少的数据也要拷贝4KB呢?因为如果这个数据被修改,那其周围的数据大概率也要被修改,每次都写时拷贝对OS是一种负担,这其实是用空间来换时间。
在4GB内存空间中,一共有100多万个页框,OS要将这些页框管理起来,先描述、再组织,用struct page管理页框,用一个结构体数组管理起来,struct page memory[1048576],用数组管理起来,这样每一个page都有了一个下标,第一个page的起始地址是0,下一个page的起始地址就是1*4KB,以此类推,就可以将每一个page下标转换为每一个内存页框的起始地址。这样,对内存的管理就变为对数组的增删查改。
另外,我们之前在谈页表时,并没有具体说,如果是4GB的地址空间,在页表中一一将虚拟地址和物理地址对应,每组对应关系再加上一个状态标志位(按1个字节),那就是一组虚拟地址和物理地址对应关系要占用9个字节,那就是36GB,这也太大了吧,所以,实际上肯定不能这样映射。那真实的页表是什么样子的?虚拟地址是如何转换为物理地址的呢?
其实,虚拟地址在OS看来不是一个整体,而是将32位拆为10位(2^10=1024)、10位(2^10=1024)、12位(2^12=4096)。首先,将虚拟地址的前10位作为索引(第一张表),所以第一张表一共1024项,这张表称为页目录,(页目录最多占4字节*1024=4KB)页目录中的每一项中存放的内容是第二张表(页表)的地址,所以页目录可以指向很多张的页表,每一张页表也是有1024项,根据第二个10位索引页表中的某一项,而页表中某一项存放的内容是指向页框的起始地址,然后再拿上虚拟地址的低12位(范围是[0,4095])作为页内偏移,是任一个字节的偏移量。总结来看,先拿着虚拟地址的前10位作为页目录的下标去索引,通过页目录这一项的内容找到所指向的页表,然后通过虚拟地址的第二个10位作为下标去索引页表的某一项,这个页表的某一项存放的是指向的页框的起始地址,最后根据虚拟地址的后12位作为某一个页框内的偏移量,就可以找到任一个字节,也就是说,任意一个虚拟地址&(0xFFF)==页框号。实际上,虚拟地址的前20位加起来的作用是搜索页框,在加上虚拟地址的低12位页内偏移,就能索引到页框里的任意一个字节。这种分配方案我们称为二级页表。
我们来算一笔账,页表里每项2个字节,一个页表就是2KB,一共有1024张页表,所以所有页表加起来一共是2MB,加上一级页目录4KB。
但是现在有些尴尬,我们只能上述方式找到一个字节的地址,所以C/C++里对每个变量都设置了类型,这样就能拿到我们想要的任何数据。
CPU读取的是虚拟地址,在CPU中要将虚拟地址根据页表转换为物理地址,那CPU如何找到页表呢?实际上,在CPU中存在cr3,每一个进程会把自己对应的页表中页目录的起始地址放在寄存器当中,这样,虚拟地址被读到CPU,找到页表后,通过CPU中的MMU电路直接找到虚拟地址,
在地址空间中,正文代码限定了一批虚拟地址空间的范围,并且依靠页表才能看到对应的实际物理空间。现在假设正文代码由20个函数组成,给 每一个函数分配一个执行流,那代码由并行转为串行,这在技术上是可行的。我们知道,函数地址其实是一批代码的入口地址,函数的每行代码都有地址,而且同一个函数我们认为地址是连续的,所以函数是连续的代码地址构成的代码块,这意味着一个函数对应一批连续的虚拟地址!将这20个函数划分,本质上是对页表进行划分,虚拟地址的本质是一种资源!!!
线程概念
先来说一下官方的概念,线程,是在进程内部运行,是CPU调度的基本单位。之前我们学过,进程之间的代码数据和内核数据结构之间都是相互独立的,那如果创建进程时不再创建地址空间和页表,而是只创建一个新的task_struct,假如正文代码分成4份,第1份代码由第1个进程来执行,第二份代码由第2个进程来执行,然后接着创建第3、4个进程,上面我们描述的就是Linux中的线程,这就是定义的第一句“线程在进程内部运行”。之间我们对进程的定义是进程=内核数据结构+进程代码和数据,现在我们站在内核的角度,给进程定义,进程是承担分配系统资源的基本实体!
说到这里,可能还不不是很理解,下面就先讲一个故事:
在我们伟大的国家,分配资源的基本实体是家庭,OS就是国家,家庭就是OS的一个个进程,在我们家庭里,会有爸爸妈妈爷爷奶奶,我们此时可能在学校上课,我们执行上课的代码,爸爸妈妈在上班,他们执行上班的代码,爷爷奶奶在遛弯,他们执行养老的代码,我们家庭的每一个人都在做着自己的事情,我们每一个人把自己的工作做好,这样就达到一个神奇的效果,就能把这个家的日子过好。每一个家庭的任务就是把日子过好,家庭中的每一个人就是一个线程。
对比一下我们之前学的进程,实际上,我们之前学的进程内部只有一个执行流,而现在的进程中有多个执行流,所以只有一个执行流的进程是有多个执行流的进程的特殊情况。
假设我们现在OS要单独设计线程,就要设计新建、暂停、销毁、调度,那线程要不要和进程产生关联,这里所说的几点就是要管理线程,先描述再组织,struct tcp,tcb结构体里面的成员就要有线程的id,优先级,状态,上下文,链接属性,而描述进程的结构体是struct pcb,每个进程有多个线程,
上图这种设计方案其实是windows中真实存在的线程控制块,CPU在调度时先选择一个进程,再选择其中一个线程。然而,在设计Linux时,发现设计线程时,线程的各种属性(id,优先级,状态,上下文,链接属性等)进程也都有,那为什么还要单独设计一个数据结构tcb来表示线程呢?此外,如果进程和线程设计成两套,那调度算法是不是也要设计成两套?所以,Linux的设计者就想能不能复用pcb,用pcb统一表示执行流,这样的话,我们就不需要为线程单独设计数据结构和调度算法了,这就是Linux的方案,用进程模拟的线程!!!显然Linux的方案更优秀。
站在CPU的角度,在Linux中,它所调度的task_struct<=进程,CPU用不用区分task_struct是进程还是线程?不用区分!因为在CPU看起来都是执行流,所以CPU看到的执行流<=进程,所以Linux中的执行流叫做轻量级进程!
话不多说,我们先来用代码来见一见线程:
在Linux中,使用pthread_create函数来创建新的线程,
第一个参数thread是一个线程id,是输出型参数。第二个参数attr是属性,一般设为null,第三个参数start_routine是一个参数为void*、返回值为void *的函数指针,一旦线程创建成功,主线程继续向下执行,新线程执行这个函数指针所对应的方法。第四个参数args是参数。
如果线程创建成功返回0,失败返回错误码。
//新线程
void* threadStart(void* args)
{
while(1)
{
std::cout << "new thread running..." << " ,pid: " << getpid() << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadStart, (void *)"thread-new");
//主线程
while(1)
{
sleep(1);
std::cout << "main thread running..." << " ,pid: " << getpid() << std::endl;
}
return 0;
}
运行以上程序,我们发现虽然只有一个进程,但是两个执行流可以一起执行,原因就是他们是属于同一个进程的线程。
其实,我们可以使用ps -aL查看线程,
我们看到这两个线程的pid是一样的,他俩还有LWP(Light Weight Process,轻量级进程),这就是线程的id。另外,我们发现,其中有一个的LWP和pid一样,所以,OS调度的时候,看的是pid还是LWP?肯定是LWP。pid和LWP相同的是主线程,不同的是新线程。
现在我们还是有两个问题:
1.已经有多进程了,为什么还要有多线程呢?
创建一个新进程,既要创建pcb,开辟地址空间,又要创建页表,还要加载代码和数据,因此创建进程成本非常高!而创建线程只需要1.创建一个pcb 2.把进程已有的资源给你,因此,创建线程成本非常低(启动)。此外,在运行期间,如果切换进程,需要保存上下文,切换进程页表、地址空间,但是在切换线程时,上下文要保护起来,但是地址空间、页表就不用切换了,因此运行期间,线程调度成本低(运行)。另外,当删除一个进程时,要释放pcb、地址空间、代码和数据等,而删除线程只需要释放pcb,因此,删除一个线程更简单(死亡)!
以上只是说的线程的优点,但是它也是存在缺点的。在多线程中,他们共享地址空间、页表、代码和数据,如果其中一个线程出现野指针报错,就是这个进程出异常了,那这个进程就被干掉了,一个线程崩溃会导致其他线程崩溃。所以,如果代码如果没写好,其健壮性会比较差。而多进程没有这个问题。进程和线程同时存在时因为它们都有不可取代性。
2.不同系统对于进程和线程的实现不一样?为什么OS课本只有1本?
我们来回顾一下线程的定义,线程,是在进程内部运行,是CPU调度的基本单位,虽然不同OS对这句话的实现不一样,但是它们都遵守了线程的定义,所以说操作系统是计算机界的哲学。
下面我们再来说一道常见的面试题,为什么线程的调度成本更低?
CPU为了加速访问内存的效率,CPU中会存在cache(硬件上),当CPU在访问某一行代码时,会把这一行附近的相关代码和热点数据全部预先加载到CPU的cache中,这一部分称为进程的热数据,所以CPU在访问数据时,先去cache中去找,找到了就直接从cache中拿数据,没找到才会去内存中拿数据,然后将这个数据置换到cache中,我们通过lscpu指令可以查看CPU中的缓存,
这就意味着,如果切换进程,除了切换pcb、地址空间、页表,对于cache中的热点数据,切换后的进程用不上,此前保存的数据全部作废,切换进程后,catch里的数据要重新写入,这个过程就比较慢了。而切换线程时,cache之前保存的热点数据可能会用到,不要丢弃所以也就不需要重新加载热数据,所以线程切换效率高。
所以,一个线程去执行一个函数的本质,就是这个线程拥有了正文代码的一小块,就是拿到了一小部分虚拟地址空间范围,也就是只使用页表的一部分,每个线程各自使用一小部分虚拟地址,所以虚拟地址本质上就是一种资源。比如一个线程拿了20行代码,另一个拿了30行代码,不就是也把页表拿了一部分吗。
如果是计算密集型应用,并不是创建的线程越多越好,而是谨慎创建合适的数量,一般是和CPU的核数一样。如果是IO密集型应用,可以允许多创建一些线程。
线程也有很多缺点:
1.健壮性降低。一个线程出问题,那这个进程直接终止,所以其他线程就终止了。
2.缺乏访问控制。由于大部分地址空间上的内容都是共享的,每个线程都能看到,一个线程可能把另一个线程的数据修改。
Linux中进程和线程对比
1.进程是资源分配的基本单位
2.进程是资源分配的基本单位
3.线程共享进程数据,但也拥有自己的一部分数据,如线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级等。
其中,线程私有的部分中,一组寄存器和栈是最重要的,一组寄存器中存放的是硬件上下文数据,这反应了线程可以动态运行;栈,线程在运行的时候,会形成各种临时变量,临时变量会被每个线程保存在自己的栈区。
进程的多个线程共享同一地址空间,因此代码段、数据段都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
线程控制
在我们编译源文件时,多加了pthread这个库,为什么会这样呢?
其实在Linux中,不存在线程,而只有轻量级进程,而作为OS学习者,只学过创建进程、终止进程、调度进程、等待进程,但是Linux系统只会给上层用户提供创建轻量级进程的接口,所以就需要存在中间软件层--pthread库,是Linux系统自带的,原生线程库,对轻量级进程接口进行封装,按照线程的接口方式,交给用户,这样就保持了Linux系统的纯粹性。
线程创建、线程等待、线程终止
在线程创建时,需要包含pthread.h,并且在编译时要加上-pthread库。在新线程创建完成后,主线程会继续向后执行,新线程转而会去执行void* threadrun(void* args)对应的代码,所以执行流就一分为二,实际上是并行运行,pthread_create的第四个参数args传给threadrun做参数传入。
同样的,在创建好线程之后,还需要对线程进行等待,使用pthread_join,
一般是由主线程等待新线程,其第一个参数thread就是pthread_create的第一个参数的返回值,第二个参数一般设为nullptr,这个参数实际上是为了得到threadrun的返回值。这个函数返回值的含义和pthread_create一样。
写了上面这段代码,我们问题1来了,main和new线程谁先运行呢?实际上是不确定的!问题2:我们期望谁最后退出?我们期望主进程最后退出,因为父进程要回收子进程的退出信息!那如何来保证main最后退出呢?就是通过pthread_join保证,new线程不退,main就阻塞式等待new线程退出。如果main不join,那主线程运行完退出,进程就退出,所有其他线程也就退出了,所以强烈不推荐这种做法,因为主线程退出了,new线程还没把任务执行完。那如果主线程不退出,也不join,此时就会造成类似于僵尸进程的问题,new线程退出时,其所对应的资源也就会被维护起来,维护起来就是等mian线程去拿new线程的返回值,如果mian一直不拿,就会造成类似僵尸进程。
void* threadRun(void* args)
{
int cnt = 10;
while(cnt)
{
std::cout << "new thread run ...,cnt : " << cnt-- << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;//unsigned long int
//问题1.main和new线程谁先运行呢?不确定
int n = pthread_create(&tid,nullptr,threadRun,(void*)"thread-1");
if(n != 0)
{
std::cerr << "create thread error" << std::endl;
return 1;
}
std::cout << "main thread join begin..." << std::endl;
//2.我们期望谁最后退出?main thread ,如何保证?
n = pthread_join(tid,nullptr);//join来保证
if(n == 0)
{
std::cout << "main thread wait success" << std::endl;
}
return 0;
}
问题3:创建了进程就有了tid,tid是什么样子?是什么呢?
我们看到,线程的id是红框里的一大串,那我们之间看到LWP好像并不是这样,
那线程id是什么呢?其实是一个虚拟地址,关于这点我们下面再谈。
问题4:全面看待线程函数传参
在这里我们传的是(void*)"thread-1",这个(void*)"thread-1"传给了threadRun的args,运行程序,发现新线程接收到了这个参数:
然而,这里要说的是,这个参数并不是只能传字符串、整数等,我们也可以传递类对象的地址,
class ThreadData
{
public:
std::string _name;
int _num;
//other
};
void* threadRun(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
int cnt = 10;
while(cnt)
{
std::cout<< td->_name << " run ...,num is " << td->_num << " cnt : " << cnt-- << std::endl;
sleep(1);
}
return nullptr;
}
std::string PrintToHex(pthread_t& tid)
{
char buffer[64];
snprintf(buffer,sizeof(buffer),"0x%lx",tid);
return buffer;
}
int main()
{
pthread_t tid;//unsigned long int
//问题1.main和new线程谁先运行呢?不确定
ThreadData td;
td._name = "thread-1";
td._num = 1;
int n = pthread_create(&tid,nullptr,threadRun,(void*)&td);
if(n != 0)
{
std::cerr << "create thread error" << std::endl;
return 1;
}
std::string tid_str = PrintToHex(tid);//按照16进制打印出来
std::cout << "tid : " << tid_str << std::endl;
std::cout << "main thread join begin..." << std::endl;
//2.我们期望谁最后退出?main thread ,如何保证?
n = pthread_join(tid,nullptr);//join来保证
if(n == 0)
{
std::cout << "main thread wait success" << std::endl;
}
return 0;
}
这样,我们就可以给线程传递多个参数,甚至方法了。但是main函数中的ThreadData td;属于在栈上开辟的空间,所以新线程访问的是主线程栈上的变量,这种做法不太推荐,一方面,破坏了主线程的完整性和独立性,另一方面,如果再来一个新线程,这个新线程通过传参还是可以访问到这个变量,一个线程把这个变量改了不就影响另一个线程了吗?所以,我们推荐在堆上申请空间,然后把在堆上申请的空间地址拷贝给线程,堆空间一旦被申请出来,其实其他线程也能看到,但必须得有地址,把这个地址交给一个线程,未来如果有第二个线程,就子啊堆上重新new一块空间交给线程,这样每个线程都有一块堆空间,这样多线程就不会互相干扰了。
我们再来谈这个函数的第二个参数retval,这是一个输出型参数,需要传一个void的变量地址,在新线程结束后,threadrun函数会返回一个void的返回值,而这个返回值会被主线程的pthread_join获取,未来要通过void* ret,把&ret传给pthread_join,从而接收到threadrun的返回值。
class ThreadData
{
public:
std::string _name;
int _num;
//other
};
void* threadRun(void* args)
{
ThreadData* td = static_cast<ThreadData*>(args);
int cnt = 10;
while(cnt)
{
std::cout<< td->_name << " run ...,num is " << td->_num << " cnt : " << cnt-- << std::endl;
sleep(1);
}
delete td;
return (void*)0;
}
std::string PrintToHex(pthread_t& tid)
{
char buffer[64];
snprintf(buffer,sizeof(buffer),"0x%lx",tid);
return buffer;
}
int main()
{
pthread_t tid;//unsigned long int
//问题1.main和new线程谁先运行呢?不确定
ThreadData* td = new ThreadData();
td->_name = "thread-1";
td->_num = 1;
int n = pthread_create(&tid,nullptr,threadRun,(void*)td);
if(n != 0)
{
std::cerr << "create thread error" << std::endl;
return 1;
}
std::string tid_str = PrintToHex(tid);//按照16进制打印出来
std::cout << "tid : " << tid_str << std::endl;
std::cout << "main thread join begin..." << std::endl;
//2.我们期望谁最后退出?main thread ,如何保证?
void* code = nullptr;
n = pthread_join(tid,&code);//join来保证
if(n == 0)
{
std::cout << "main thread wait success,new thread exit code : " << (uint64_t)code << std::endl;
}
return 0;
}
我们可以看到,主线程接收到了新线程的退出码。
问题5:如何全面看待线程函数返回
a.只考虑正确的返回,不考虑异常,因为异常了,整个进程就崩溃了,包括主进程。
b.我们可以传递任意类型,也可以传递类对象的地址。
所以,这里我们应该能理解,为什么pthread_create的第三个参数函数指针的参数是void、返回值是void,这样我们就能传入任意类型、返回任意类型。
问题6:如何创建多线程呢?
我们直接来看代码:
std::string PrintToHex(pthread_t& tid)
{
char buffer[64];
snprintf(buffer,sizeof(buffer),"0x%lx",tid);
return buffer;
}
const int num = 10;
void* threadrun(void* args)
{
std::string name = static_cast<const char*>(args);
while(true)
{
std::cout << name << " is running..." << std::endl;
sleep(1);
break;
}
return args;
}
int main()
{
std::vector<pthread_t> tids;
for(int i = 0 ; i < num ; i++)
{
//1.有线程的id
pthread_t tid;
//2.有线程的名字
char* name = new char[128];
snprintf(name,128,"thread-%d",i+1);
pthread_create(&tid,nullptr,threadrun,/*线程名字*/name);
//3.保存所有线程的id信息
tids.emplace_back(tid);
}
//join to to
for(auto tid : tids)
{
// std::cout << PrintToHex(tid) << " quit..." << std::endl;
void* name = nullptr;
pthread_join(tid,&name);
std::cout << (const char*)name << " quit..." << std::endl;
delete (const char*)name;
}
return 0;
}
问题7:新线程如何终止?
我们知道,main函数结束,main thread结束,表示进程结束,而新线程结束,只代表自己结束了。
a.函数return。不能使用exit终止一个线程,exit是专门用来终止进程的,不能用来终止线程。
b.接口pthread_exit。这个接口等于线程内部的return。
c.main thread调用接口pthread_cancel。线程能被取消,前提是线程得存在。
一般都是主线程取消新线程。新线程被取消的退出结果是-1。-1的定义如下:
#define PTHREAD_CANCELED ((void *) -1)
问题8:可不可以不join新线程,让他执行完就退出呢?
可以!
pthread_datach函数,即线程分离,
a.一个线程被创建,默认是joinable的,必须要被join的。
b.如果一个线程被分离,线程的工作状态是分离状态,不需要/不能被join的,依旧属于进程内部,但是不需要被等待了。
现在,新线程要主动和主线程分离,先来在认识一个函数pthread_self,类似与getpid,哪个线程调用pthread_self,就返回哪个线程自己的id。
在threadrun函数中,让新线程和main thread分离:
void* threadrun(void* args)
{
pthread_detach(pthread_self());//和main thread分离
std::string name = static_cast<const char*>(args);
while(true)
{
std::cout << name << " is running..." << std::endl;
sleep(3);
break;
}
// return args;
pthread_exit(args);//专门用来终止一个线程的
// exit(1);
}
同样的,main thread也可以主动和新线程分离,前提是新线程必须存在:
说完Linux下的多线程,我们现在有一个小插曲,其实C++11也有多线程,也有其原生线程库,我们使用C++11创建线程,
#include<iostream>
#include<string>
#include<vector>
#include<thread>
#include<unistd.h>
#include<stdlib.h>
#include<pthread.h>
void threadrun(int num)
{
while(num)
{
std::cout << "thread-1: " << " num : " << num << std::endl;
num--;
sleep(1);
}
}
int main()
{
std::string name = "thread-1";
std::thread mythread(threadrun,10);
while(true)
{
std::cout << "main thread..." << std::endl;
sleep(1);
}
mythread.join();
return 0;
}
如果我们在Makefile只用C++11,
这样编译时其实会报错:
所以,C++11中创建多线程编译时,也要加-lpthread,
然后,编译运行以上程序,我们看到结果:
所以,C++11中的多线程本质,就是对原生线程库接口的封装!!!
实际上,无论Linux、Windows还是MacOS,每一款操作系统都有自己对应的创建进程方案,但为什么说语言具有跨平台性呢?因为无论在什么平台下,C++11代码都是一样的,在Linux、Windows、MacOS中都提供了C++11的库,对上都提供了一样的线程接口,所以语言上是同一种创建线程的方式,但是每一种平台实现库的方式肯定是不一样的。所以未来任何语言,只需要把原生线程库的接口学懂了,上层语言只需要熟悉接口就可以了。文件操作,也是如此!
到现在,我们接着谈问题3中提到的tid到底是什么?我们写了如下代码,编译运行,
std::string ToHex(pthread_t tid)
{
char buffer[128];
snprintf(buffer,sizeof(buffer),"0x%lx",tid);
return buffer;
}
void* threadrun(void* args)
{
std::string name = static_cast<const char*>(args);
while(true)
{
std::cout << name << " is running...,tid: " << ToHex(pthread_self()) << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadrun,(void*)"thread-1");
std::cout << "new thread running,tid: " << ToHex(tid) << std::endl;
pthread_join(tid,nullptr);
return 0;
}
我们发现用户级所看到的tid值和其LWP肯定不相等,所以这里我们得出一个结论:给用户提供的线程ID,不是内核中的lwp,而是由pthread库自己维护的一个唯一值,其实也好理解,LWP是轻量级进程的ID,不需要呈现给用户,库内部也要承担对线程的管理。
实际上,tid是一个地址,要理解tid,我们首先要理解pthread库,和动态库类似,pthread库就是存在于磁盘上的一个文件,
mythread也是磁盘上的一个文件(可执行程序),mythread运行时,要首先被加载到内存上,在内存上就要有自己的代码和数据,然后也要配套有pcb、地址空间和页表,CPU调度这个程序时就会去执行内部代码。接下来创建线程,要提前把库加载到内存,映射到我进程的地址空间!!!具体来说,就是映射到地址空间的堆栈之间的共享区,未来想访问pthread库中任意一个函数的地址,都能通过页表找到对应的方法。
而我们可能正在运行多个和mythread一样的程序,此时只需要把它地址空间的共享区通过页表映射到已经加载到内存中的pthread库,此时多个进程就能使用同一个库里的方法来进行线程创建了,所以pthread库叫做共享库,这样每一个进程都可以只用pthread共享库来创建多线程了。具体示意图如下:
实际上,Linux维护的是轻量级进程的属性,可是在用户层用的是线程,我要的是线程的ID、状态等属性,可是与线程相关的属性在Linux内核中是没有的,所以线程相关的属性就要由库进行维护,pthread库对线程也具有分配ID的功能,要承担对线程的管理,所以pthread库如何做到对线程管理呢?先描述,再组织!我们在使用pthread_create的时候,什么叫做创建线程呢?创建线程又做了什么呢?只有内核中的LWP是不够的,LWP中不包括任何包含线程的概念。所以创建线程时,pthread库会为我们创建一个上图中的结构(其实就是一个结构体),也就是申请了一个内存块,其中第一项struct pthread中存放了线程在用户级最基本的属性,第三项线程栈就是用户级线程所拥有的独立的栈结构。每创建一个线程就给我们申请这样的内存块,所有的内存块连续存放。所谓先描述,这个结构体包含了库中创建描述线程的相关结构体字段属性;所谓再组织,可以先理解为把内存块用数组的形式管理起来。换言之,未来如果我们想找一个线程的所有属性,我们只需要找到线程控制块的地址就可以了,所以pthread_t id就是一个地址,就是线程控制块的地址!怎么理解呢?我们之间学fopen的时候,其返回值FILE*中的FILE是一个结构体,里面包含了文件的相关属性,那这个FILE对象在哪里呢?在C标准库里!所以我们访问文件对象不就是在拿着地址访问文件对象吗?
所以,我们应该能理解,在pthread_join的时候,是在拿着线程ID找到对应的线程控制块,然后从里面取出线程返回值给*retval,再释放对应的线程控制块,所以这就是为什么我们能拿到线程退出结果的原因。
因此,Linux线程=pthread库中线程的属性集+LWP,是1:1的。那lwp线程在运行怎么怎么保证把自己产生的临时变量存放在自己的线程栈上呢?既然有lwp概念,那么必然后lwp的系统调用,比如clone:
lwp可以调用clone第二个参数就可以指定栈空间,而pthread库内部就是类似对这种系统调用的封装。
那内存控制块中的线程局部存储是干什么用的呢?我们来看以下程序:
int gval = 100;
std::string ToHex(pthread_t tid)
{
char buffer[128];
snprintf(buffer, sizeof(buffer), "0x%lx", tid);
return buffer;
}
void *threadrun(void *args)
{
std::string name = static_cast<const char *>(args);
while (true)
{
std::cout << name << " is running...,tid: " << ToHex(pthread_self()) << " ,gval:" << gval << " ,&gval:" << &gval << std::endl;
gval++;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadrun, (void *)"thread-1");
while (true)
{
std::cout << "main thread running ,gval:" << gval << " ,&gval:" << &gval << std::endl;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}
我们发现,只要新线程改变了全局变量gval的值,主线程也能立即看到。那么,如果gval比较特殊,不能共享,只能让它们各自单独拥有一份gval,所以在Linux的g++中,存在__thread,用__pthrea去修饰gval变量,然后再次运行程序,
原因就在于,在编译时,一旦一个内置变量被__thread修饰,这样就在每个线程的线程控制块中各自存一个gval,这就叫线程局部存储。注意,__thread只在Linux下有效,并且只能修饰内置类型。
版权归原作者 核动力C++选手 所有, 如有侵权,请联系我们删除。