目录
6.4 pthread_create()函数的第四个参数arg
6.5 pthread_create()函数的第一个参数pthread
一、进程地址空间和页表再理解
在了解线程之前,先来对进程地址空间和页表进一步理解。
首先,我们要对进程地址空间和页表有一个认识,那就是进程地址空间是“进程能看到的资源窗口”,而页表则是“决定进程真正拥有的资源状况”。这两个概念都很好了解,因为我们知道,在32位系统下,每个进程都拥有4GB的虚拟内存,在要读写数据调用资源时,都需要通过页表来找到该进程在物理内存上保存数据的位置。因此,通过合理的对地址空间+页表进行划分,就可以对一个进程的所有资源进行分类。
而为了方便管理,页表中除了物理地址的映射,其实还包含了很多其他属性,例如是否命中,对应数据的RWX权限,U/K权限等内容
那么大家有没有想过,虽然一直都在说进程的数据需要通过虚拟地址+页表映射来找到它物理内存上的位置,但是,这个页表是映射这么多虚拟地址的呢?要知道,一个32位系统下的虚拟地址就有2^32,约42亿个。如果这个页表是一个虚拟地址映射一个物理地址,假设一个地址占4字节,它对于每个地址的属性占1字节,再加上2的内存对齐,就意味着页表中保存一个地址映射的信息就需要6字节。如果采用一个虚拟地址映射一个物理地址的方法,一个进程的页表仅仅只是保存这些地址映射,就需要24GB。很明显是不现实的。
同时我们要知道,物理内存上的空间其实也是经过划分了的。在物理内存中,所有的空间都被划分为了一个个小数据块,这些小数据块一般被叫做“页框”,一般是4KB大小。当然,这些数据块也是需要被管理起来的,所以每个小数据块都有一个保存了它的相关属性的结构体,被叫做“页”。而为了便于管理这些结构体,就将这些结构体放到了一个数组中。
大家应该也知道,在磁盘中,磁盘的空间其实也是按照4KB大小进行了划分的。而我们在磁盘上的数据就被保存在这一个个4KB大小的数据块中,这些数据块被叫做“页帧”。因此,当磁盘与内存进行交互时,就是以4KB为单位进行交互的,与内存中的数据块划分一致。
当然,内存中的这些数据块要被管理起来,也是需要有配套的管理算法的,一般linux中的管理算法叫做“伙伴系统”,这里就不再做介绍,有兴趣的话大家可以自行了解。
再回到页表映射的问题上来。页表映射其实是采用的“10 10 12”的映射方案。我们知道,一个虚拟地址是32bit位,这里的“10 10 12”指的就是将这32个bit位划分为“10”,“10”,“12”三个部分。而页表其实也并不是只有一张页表。首先,页表中的一张页表,就是“页目录”。这个页目录中就保存了地址的前10个bit位,即2^10个地址。而2^10次方,算下来也就1KB左右,再加上其他的各种属性,可能也就10KB左右的空间。
有了页目录后,第二层就是“页表项”。每个页目录中的每个值都有一个对应的页表项,这个页表项中也保存了10个bit位,这10个bit位其实就是虚拟地址中的第10 ~19个bit 位。而页表项中保存的内容就是“指定页框的物理起始地址”。到了这里,虚拟地址就还剩下后12位,而这后12位,就是页内偏移量,即这后12位+页表项中保存的页框的起始物理地址,就是进程中一个数据所对应的物理地址。
而一个页框的大小是4KB,它的偏移量最大就是2*12次方,这也就是为什么要以虚拟地址的后12位作为偏移量。通过这种映射和页内偏移的方式寻址,就能讲页表的大小压缩的很小了。
二、线程
1.线程的概念
在了解线程之前,我们先来回顾一下进程。大家知道,一个程序运行起来,就会在内存中生成一个进程,而为了管理这个进程,就会有进程PCB,这个PCB里面有一个指针,指向的就是该进程所拥有的虚拟地址空间。进程在运行时所需要的资源都会通过这个虚拟地址空间中保存的地址,通过页表+mmu映射到物理内存中,页表用于维护虚拟地址,mmu用于将虚拟地址转化为物理地址,转化方案就是上面的第一节的内容。
在以前,我们对进程的理解就是“进程 = 内核数据结构 + 进程对应的代码和数据”,而一个进程就是内存中的一个执行流。而虚拟地址空间,就可以看做是一个进程所能看到的“资源”,换句话说,虚拟内存中的内容就决定了进程能够看到的“资源”,例如它里面的堆区、未初始化数据区、已初始化数据区等。
那么这些“资源”能否被划分为一个个小块呢?答案是可以的。例如在父进程中创建一个子进程,通过if判断的方式让子进程执行其他代码,这其实就是将父进程中的资源划分出了一个小块交给子进程去执行。但是,通过这种方式创建的进程还是需要创建属于它自己的结构体,虚拟地址空间、页表等内容。
既然如此,按照子进程的思想,我们就可以将父进程的这块虚拟地址空间划分为一个个的小块,然后只创建PCB,让这些PCB都指向父进程的虚拟地址空间中的一部分资源。这就意味着这些PCB和父进程共享同一块地址空间,进行映射时也是通过同一个页表映射。
这种“只创建PCB”,让进程给它分配资源的执行流,就叫做“线程”。所以,线程其实就是进程中的一个执行流。这就和在父进程中创建一个子进程,让这个子进程去执行指定的代码块有点像。只不过线程并不需要像子进程那样拷贝一份地址空间和页表,只需要创建一个PCB即可。
按照上面的说法,那么在一个进程中就可能存在大量的线程。以windows为例,大家可以打开自己电脑上的资源监视器,点到CPU,就会发现,在windows中的进程运行时,就存在大量的线程:
这也就直接印证了上面的说法,即一个进程中可能存在大量的线程。既然在进程中存在大量线程, 这些线程就必定需要被管理起来,那么如何管理呢?很明显,就需要为线程设计专门的数据结构表示线程对象,如TCB。然后在进程中以链表或其他数据结构,将这些线程链接起来。这种解决方案,就是windows所使用的方案。但是,这仅仅只是windows所使用的方案,而每个系统下关于线程的解决方案可能都会有所不痛,其中,linux的线程方案就和windows的方案天差地别。这里我们主要讲的是linux的线程方案,所以就不再过多赘述windows的线程方案了。
诚然,windows的线程方案是可行的,但是这也导致了它的方案实现起来非常的复杂。而我们仔细思考一下,线程作为进程中的一个执行流,它需要去执行进程中的某个代码块,这也就意味着它的PCB中也需要包括id、 状态、 优先级、 上下文、栈等进程PCB都拥有的内容。因此,单从线程调度的角度来看,线程和进程有很多地方都是重叠的。按照这个理论,其实就并不需要给线程单独设计一个数据结构,而是直接复用进程的PCB即可。
到这里,我们就可以对线程有更进一步的理解了,即“线程其实就是在进程内部,即进程的地址空间内运行,拥有进程的一部分资源”。
2. 进程与线程
看到这里,大家可能就会有一个疑问。前文中才说过,“进程 = 内核数据结构 + 进程对应的代码和数据”。而一个进程只有一个内核数据结构,那这里的这些线程又算是什么呢?其实上文中的说法并没有错,但是那只是以前对进程的单个执行流的理解。到现在,我们对进程的认识就应该更新为“进程是承担分配系统资源的实体”</