前言: 在上一篇中我们结束了Linux信号章节的全部内容,下面我们将进入新的篇章:Linux线程, 而今天的这篇文章的主要内容是初识线程,帮助我们来迈进线程的大门,对Linux中的线程有个大概的了解,便于我们对后续内容的讲解。
废话不多说,下面我们来进入今天的内容:
这里我们直入主题,直接给大家输出进程和线程的概念:
进程:一个运行起来的执行流,一个加载到内存中的程序,进程 = 内核数据结构 + 自己的代码和数据。 线程:是进程内部的一个执行流,更加轻量化。 观点:进程是系统分配资源的基本单位,而线程是cpu调度的基本单位。
我们对于第一条是很熟悉的,这是我们所常谈的,而我们对于后面所输出的内容却不甚了解,比如:什么叫线程是进程内部的一个执行流?为什么线程更加地轻量化?为什么说线程是cpu调度的基本单位呢?
而我们今天的任务就是解决上面的疑问,并对上面的问题进行补充。
而在讲解线程的相关内容前,我们要先对曾经讲过的关于虚拟地址空间的内容做一个收尾,有了对这部分知识的了解,我们理解线程会更加的从容。

我们之前讲过,页表存储的是虚拟地址和物理地址之间的映射关系,也就是页表也是一种内核数据结构,也要在内核中存在,也要占据物理内存,那么我的问题是:页表构建的映射关系,是按字节进行映射的吗?
要解决这个问题,我们不妨就按照字节进行映射来观察一下页表的大小:

我们以虚拟地址空间为4GB来计算,那么整个虚拟地址空间就有上面的4 * 1024 *1024 *1024个字节,而这还只是虚拟地址空间的个数,再加上物理地址的个数,还要 * 2来计算,也就是目前的页表要存储8 * 1024 * 1024 * 1024个地址。
而每个地址的大小我们按照4个字节的大小来计算的话,那就是:

也就是整个页表的大小是32GB,并且这还只是一个进程的页表大小,大家觉得可能吗?
当然不可能啊,要知道我们的电脑中可不止一个进程,是有很多进程的,要是每个进程的页表大小都为32GB,那我们的电脑就不可能装得下那么多进程。
所以我们可以得出一个结论:页表不是按照字节进行映射的!!!
那页表是按照什么进行映射的呢?这就与下面我们要讲的虚拟地址空间有关。

我们之前在讲解磁盘的时候,说过磁盘中的文件,其内容和属性都是以4kb为单位进行保存的,也就是保存在一个个的块中,并且我们也说过内存访问磁盘中的文件,也就是IO操作,也都是以4kb为单位去访问的。
那么哪儿哪儿都是4kb,理论上内存也应该是按照4kb进行划分的,这样才能更好的将磁盘中的文件内容和属性加载到内存中,大家想想是不是这个理,那么内存就应该是:

如上图所示,物理内存就应该是4kb为单位进行划分的,而这4kb的每块空间我们也给它起了个名字,叫做:页框或者页帧。
每块空间只有4kb,而如果我们的物理内存是4GB,那么就应该有1024 * 1024个页框,也就是有一百多万个页框,数量很多,那么操作系统要不要管理这些页框呢?
当然要管理,所以该如何管理呢?我们来看:
/* include/linux/mm_types.h */
struct page
{
/* 原⼦标志,有些情况下会异步更新 */
unsigned long flags;
union
{
struct
{
/* 换出⻚列表,例如由zone->lru_lock保护的active_list */
struct list_head lru;
/* 如果最低为为0,则指向inode
* address_space,或为NULL
* 如果⻚映射为匿名内存,最低为置位
* ⽽且该指针指向anon_vma对象
*/
struct address_space *mapping;
/* 在映射内的偏移量 */
pgoff_t index;
/*
* 由映射私有,不透明数据
* 如果设置了PagePrivate,通常⽤于buffer_heads
* 如果设置了PageSwapCache,则⽤于swp_entry_t
* 如果设置了PG_buddy,则⽤于表⽰伙伴系统中的阶
*/
unsigned long private;
};
struct
{ /* slab, slob and slub */
union
{
struct list_head slab_list; /* uses lru */
struct
{ /* Partial pages */
struct page *next;
#ifdef CONFIG_64BIT
int pages; /* Nr of pages left */
int pobjects; /* Approximate count */
#else
short int pages;
short int pobjects;
#endif
};
};
struct kmem_cache *slab_cache; /* not slob */
/* Double-word boundary */
void *freelist; /* first free object */
union
{
void *s_mem; /* slab: first object */
unsigned long counters; /* SLUB */
struct
{ /* SLUB */
unsigned inuse : 16; /* ⽤于SLUB分配器:对象的数⽬ */
unsigned objects : 15;
unsigned frozen : 1;
};
};
};
...
};
union
{
/* 内存管理⼦系统中映射的⻚表项计数,⽤于表⽰⻚是否已经映射,还⽤于限制逆向映射
搜索*/
atomic_t _mapcount;
unsigned int page_type;
unsigned int active; /* SLAB */
int units; /* SLOB */
};
...
#if defined(WANT_PAGE_VIRTUAL)
/* 内核虚拟地址(如果没有映射则为NULL,即⾼端内存) */
void *virtual;
#endif /* WANT_PAGE_VIRTUAL */
...
}答案就是先描述在组织,那么在内核中就有一个结构体名为:page,这个结构体就是用来描述内存中的每个4kb的空间,而上面就是我从Linux源码中截取的关于page结构体的相关代码。
在这个结构体中也有一些变量等,在这里面我就简单的来介绍一下里面的第一个变量:unsigned long flags。 unsigned long flags和我们在讲解文件系统时的判断保存文件内容和属性的块是否被占用是很相似的,所以这个变量就是一个位图,里面不同的比特位就表明当前的这个4kb空间是否被占用,并且被占用时的状态是什么。
#define L_PTE_PRESENT (1 << 0)
#define L_PTE_FILE (1 << 1) /* only when !PRESENT */
#define L_PTE_YOUNG (1 << 1)
#define L_PTE_BUFFERABLE (1 << 2) /* matches PTE */
#define L_PTE_CACHEABLE (1 << 3) /* matches PTE */
#define L_PTE_USER (1 << 4)
#define L_PTE_WRITE (1 << 5)
#define L_PTE_EXEC (1 << 6)
#define L_PTE_DIRTY (1 << 7)
#define L_PTE_COHERENT (1 << 9) /* I/O coherent (xsc3) */
#define L_PTE_SHARED (1 << 10) /* shared between CPUs (v6) */
#define L_PTE_ASID (1 << 11) /* non-global (use ASID, v6) */上面就是这个位图中不同的比特位所代表的状态,比如:L_PTE_USER就表示当前的页框是被用户占用的。
讲完了也页框是如何被描述的,那么这些页框是怎么被组织的呢?

在内核中有一个名为:pages的数组来组织这些页框,数组中存储的每一个数据都指向一个struct page结构体,有了这个数组OS就由对整个物理内存的管理变为了对数组的增删查改!!!

那么我们假设物理内存的4kb内存块由上到下是从高地址到低地址,那么就如我们上图所说的在这个数组中0号下标所对应的page就表示第一个4kb的内存块,1号下标所对应的page就表示第二个4kb的内存块,以此类推。
不只是知道了上面的对应关系,我们还可以知道每个4kb内存块的起始地址和结束地址:
起始地址 = 数组下标 * 4kb 结束地址 = 起始地址 + 4kb
那么此时就有一个问题要问大家了:操作系统内部还需要保存每个4kb内存块的物理地址吗?
答案是不需要了,我们只需要通过上面的方式就能知道每个4kb内存块的起始地址和结束地址了。
所以我们向内存申请一个4kb的内存块,本质上就是在数组中申请一个struct page结构体,知道了struct page的下标,这块空间的所有地址我们就全都有了。 那么OS该如何得知所有物理内存块的地址呢? 答案是只要得到pages数组的起始地址就可以了,也就是将该数组定义为全局的。
有了对上面知识的了解后,相信大家此时心中对于页表是按照什么进行映射的就有了答案:

那么现在就为大家揭晓页表的真面目,我们之前认为页表就是只是一张表,其实不然,真正的页表其实是一个二级页表,在cr3寄存器中存储的其实是页目录表的起始地址,在这个页目录表中每一个页表项存储的都是一个页表的起始地址。
而这些页表中的每一个页表项存储的则是每一个4kb内存块的起始地址,所以我们现在就知道了页表是按照4kb来进行映射的,这样不仅能很好的节省空间,同时也能完成虚拟地址和物理地址之间的映射工作。
那么我们在见证了页表的真面目后,虚拟地址和物理地址该如何通过页表来进行转化呢?

这里一张图就为大家解释清楚,我们知道虚拟地址共有32位,那么我们规定前10位所对应的数据代表页目录表的下标,通过前10位我们就能准确找到某个页表。 而我们规定中间的10位所对应的就是页表的下标,通过这个数据我们能准确定位到某个页框,而我们要访问的数据就在内存中的这个页框里面。 而我们通过页表只能定位到数据具体在哪个页框中,并不能准确定位数据所在的字节,那么此时就轮到虚拟地址的后12位发挥作用了,这12位就表示数据相较于当前页框起始地址的偏移量。 通过:起始位置 + 偏移量的方式,我们最终就能准确的知道数据在内存中的位置,精确到了字节!!!
通过上面的介绍我们知道了:从虚拟地址转换到物理地址,默认是没有直接转化到字节的,查页表,只能帮我们找到你要访问哪一个页框!!!
至此我们就完成了对虚拟地址空间的全部介绍了,给大家建立了一个框架,但是对于上面的内容我还有几个细节要交代一下:
细节1:cr3寄存器中存储的是页目录表的地址,而这个地址是物理地址,不是虚拟地址。 细节2:进程首次加载磁盘块的时候,OS系统要做什么? 既然要加载到内存中,那么这个工作就属于内存管理,要去申请内存,也就是去申请数组中的page,进而得到page的数组下标,之后通过计算就可以得到内存块的起始地址,最后填充页表。 细节3:如果我们访问的是一个int?一个结构体?一个数组?一个类变量呢? 对于上面的各种变量,我们都知道它们虽然占据不同的字节数,但是它们的地址只有一个,那就是开辟空间的最小字节的地址(虚拟地址),那么通过页表转化的时候,就只能拿到一个字节的具体地址啊,但是它们并不只有一个字节,该怎么办呢? 所以在语言中就引入了" 类型 "的概念,虽然我们通过页表转化只能拿到一个字节的具体地址,但是根据类型的不同,相对于最小字节的地址的偏移量(类型)也就不同,进而就能涵盖不同类型变量的所有字节了。 细节4:如何重新理解写时拷贝? 我们知道写时拷贝,就是重新开辟一块空间,将数据分离开来,互不影响,而在OS中,我们现在知道申请和管理物理内存都是以4kb为单位的,所以写时拷贝申请空间也都是以4kb为单位进行申请的!!! 细节5:我们使用new,malloc的时候,怎么直接就是申请1,2,...,n个字节呢? new和malloc既然要申请空间,涉及到了内核,那么必然就要调用系统调用,而new,malloc底层的系统调用是brk或者mmap,但是我们知道调用系统调用是需要成本的。 所以为了减少系统调用的成本,C,C++自己在语言层面,会有自己的内存管理机制,类似STL中的空间配置器,也就是我们虽然使用new或者malloc只申请1个或多个字节,但是C,C++在语言层面会直接申请更大的空间,不过只给我们使用我们申请的空间。
交代完上面的内容后,下面我们就要真正进入线程的环节了。
与其给大家说一堆概念,不如我们直接来写一个简单的多线程的代码,看看多线程在运行时会有什么现象。

而要使用多线程,那么我们首先要做的就是先创建线程,那么就要用到:pthread_create函数,从它的介绍中我们可以看到它的作用就是创建一个线程。

它的返回值也比较简单,成功就返回0,失败会返回一个错误码,这个错误码是一个int类型的整数,并不是失败返回-1。
下面我们来看看这个函数的参数都有什么作用:
pthread_t *thread:这个参数是一个输出型参数,它所带出来的就是线程的ID,也就是这个线程的标识符,和进程的pid是一个意思。 const pthread_attr_t *attr:这个参数的作用是用来设置线程的属性,一般情况下我们传入nullptr即可。 void *(*start_routine) (void *):这个参数我们看类型就知道这是一个函数指针,这个参数就是未来线程要执行的函数。 void *arg:我们看到前面的函数参数类型是void*,所以这个参数的作用传递给线程函数的参数。
而要使用这个函数还有最后一个注意点:


我们在自己的程序中使用这个函数后,在编译时要在后面加上-lpthread,也就是我们之前讲的要告诉编译器我要使用的是哪个库。
讲解完这个函数的各种内容后,下面我们就用这个函数来创建一个线程,看看效果:
void *thread_routine(void *agrs)
{
while (true)
{
cout << "我是新线程, 名字:" << (const char*)agrs << endl;
sleep(1);
}
}
int main()
{
pthread_t pid;
// 创建线程
pthread_create(&pid, nullptr, thread_routine, (void *)"thread->1");
while (true)
{
cout << "我是主线程" << endl;
sleep(1);
}
return 0;
}
从结果中我们可以看到,一个程序同时执行了两个死循环,在这之前一个程序,可能同时让两个死循环跑起来吗?
当然是不可能的,在我们日常写代码的过程中一个程序只能同时让一个死循环跑起来,所以现在我们的进程内部现在已经有两个不同的执行流了,也就是:

而我们将执行main函数的叫做主线程,把执行其他函数的叫做新线程,当然只看上面的代码我们对于线程的理解还是不够的,我们接着看。


这里我们对刚才的可执行程序进行反汇编,可以看到这里面的各种函数,包括main函数在内,都已经有了自己的虚拟地址,所以现在问大家一个问题:进程页表的本质是什么?
是进程看到资源的" 窗口 ",所以谁拥有更多的虚拟地址,也就拥有更多的物理内存资源。 而对函数进行编制,让不同的执行流去执行不同的函数,函数又是虚拟地址的集合,所以让不同的线程,执行不同的函数本质上就是让不同的线程拥有不同区域的虚拟地址,也就拥有了不同的资源。
而通过函数编译的形式,不同的线程就拥有了不同的资源,这不就是对进程资源的" 划分 "吗?

上面的图相信大家都已经很熟悉了,正是进程在内核中的相关内容,那么我们想一下:这是进程的一套东西,如果我们的内核中还有线程的话,会有什么呢?
在上面的代码中我们既然可以创建一个线程,那么就能创建更多的线程,也就是在OS内部会存在大量的线程,那OS系统要不要管理这些线程呢? 答案当然是要的,那么依旧是先描述在组织,也就是在操作系统内部也会存在描述线程的结构体,同时也会有组织线程的结构,实现容器化管理线程,乃至于各种调度算法。
但是我们要知道,一个优秀的操作系统是会进行软件复用的,我们可以发现针对线程的内容,和进程是非常相似,那么在Linux中会如何做呢?

在Linux中的做法就是只创建task_struct,也就是多个task_struct共用一套东西,包括:虚拟地址空间,页表等,每个task_struct只会执行代码区中的一部分,也就是我们上面讲的资源" 划分 ",而这,不就是线程吗?
Linux中采用的这种做法是很卓越的,所以在Linux中:线程的实现,是用进程模拟的,复用了进程的代码和结构,线程未来就是在进程的虚拟地址空间中运行的!!!
所以我们在最初对线程的描述中,说线程更加轻量化,上面就是原因,它不用和进程一样还要创建新的虚拟地址空间,页表等结构,只需要创建一个task_struct结构体即可。
至此我们就可以输出一个结论:一个进程至少包含一个线程!!!
那么可能有人会问:如何让不同的线程只访问虚拟地址空间中的一部分资源的?
答案就是只需要让不同的进程未来执行不同函数的入口地址即可。
有了对上面的理解后,我们下面就可以见见这张图了:

那么我们先来看一些内容:
1.什么叫做进程? 承担分配系统资源的基本实体 2.如何理解我们之前讲的进程? 就是只有一个执行流(线程)的进程 3.什么叫做线程? OS系统调度的基本单位 4.如何理解今天的进程? 就是在内部有1个或者多个执行流(线程)的进程
在这里面相信有了上面的简单了解后,第二个问题和第四个问题就不用再介绍了,我们先面着重介绍第一个问题和第三个问题。
我们先解决第三个问题:要解决这个问题我们就要把视角从进程转移到cpu,那么现在我问大家:在cpu的角度,它会区分现在执行的是进程还是线程吗? 并不会,在cpu看来,每一个task_struct都是一个执行流,不过现在的task_struct粒度 <= 传统的进程,cpu虽然看到的还是PCB,但是已经比传统的进程更加的轻量化了,所以在cpu角度:不区分进程线程,统一叫作:轻量化进程!!! 所以现在我们就能理解为什么线程是OS系统调度的基本单位了,因为cpu把所以的线程都看成是:轻量化进程,调度时调度的也是轻量化进程。 第一个问题:所以为什么说进程是分配系统资源的基本实体呢? 从上面的内容中我们知道了一个进程至少含有一个线程,也可以有多个线程,我们把一个进程比作一个家庭,把进程内部的线程都当作家庭成员,那么我问大家一个问题:如果国家现在分配房子,是按照每个人进行分配的,还是按照一个家庭进行分配的? 答案当然是按家庭分配的,要是按每个人分配房子,中国哪有那么多房子,所以OS和国家是一样的,也同样会根据每个进程来分配资源,进程中的一个一个的线程都有自己的事情要做,通过协作关系来更好地维护这个家庭。 所以才把进程称为承担分配系统资源的基本实体,它就像一个家庭一样。
说了这么多,你怎么证明一个进程中是有多个执行流的呢?我们来看:


我们通过ps -aL命令就可以看到此时就有两个执行流,这两个执行流的PID是一样的,说明它们同属于一个进程,而后面的LWP(Light Weight Process)不一样证明了它们是不同的执行流。
那么此时我问大家一个问题:OS系统调度,是看pid还是看lwp呢?
有了上面的只是做铺垫后,这里的答案就是lwp了,在我们讲解线程之前,一个进程中是只有一个主线程的,所以此时的pid和lwp在数值上是一样的。
Linux线程的实现 vs 操作系统教材的线程概念的对比:
在教材中,说线程是cpu调度的基本单位,线程在进程内部运行,这句话没什么问题,但不适用于所有的操作系统,为什么这么说呢?
在Linux中实现线程的方案是通过进程模拟的,也就是在Linux中是没有线程的实体的,而windows则不一样,它并没有用进程模拟,而是真的创建了线程,并为其配备了一套和进程相似的东西,这一点就不如Linux方便。
那么既然LInux中就没有线程的概念,那我们是如何通过pthread_create等函数来操作线程的呢?
答案就是系统调用,我们来看:

这个系统调用就是:clone,因为Linux中并没有线程的概念,所以并不会为其创建一套相关的系统调用函数,给用户提供的系统调用就只是轻量级进程的系统调用。
不过我们不讲这个函数,因为它太难用了,我们来看:

但是我们用户通过教材就只认进程和线程啊,难不成我用你Linux还得学习什么叫做轻量级进程吗,所以就封装实现了:pthread库,也叫做:原生线程库。
由这个库对外提供线程相关的接口,就比如上面我们使用的pthread_create函数,这个库的实现是很有必要的。
后面我们就会讲解这个库中的其他函数,都是针对线程操作的。
1.创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多。 2.与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多 最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。 另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚拟内存空间的时候,处理的 ⻚表缓冲 TLB (快表) 会被全部刷新,这将导致内存的访问在⼀段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有 硬件cache 。 3.线程占⽤的资源要⽐进程少 4.能充分利⽤多处理器的可并⾏数量 5.在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务 6.计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现 7.I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
这里主要解释一下第2点提到的线程之间的切换需要操作系统做的工作要少很多,这体现在3个方面:
1.我们知道 cpu中的cr3寄存器存的是当前进程的页表基地址,那么线程之间的切换不涉及到进程的切换,所以cr3寄存器中的内容不需要切换。 2.在上面我们提到 TLB(快表),它是cpu内部的一个硬件组件,它的作用就是存储虚拟地址到物理地址之间的映射关系,起到缓存的作用。 它的作用和我们在文件系统中讲的dentry结构体的作用很相似,都是起到缓存的作用, 以此来提高查询效率,而线程切换不是进程切换,所以也不需要更新TLB中的映射关系。 3.第三点也是对重要的一点,就是针对 cache缓存,这个硬件想必大家并不陌生,它里面存储的是内存数据,是以mb为单位进行存储的,内容挺多的。 而线程切换不是进程切换,所以cache缓存中的内存数据也就不需要更新了,就这一步就比进程切换少了很多工作!!!
1.性能损失 ⼀个很少被外部事件阻塞的计算密集型线程往往⽆法与其它线程共享同⼀个处理器。如果计算密集型线程的数量⽐可⽤的处理器多,那么可能会有较⼤的性能损失,这⾥的性能损失指的是增加了额外的同步和调度开销,⽽可⽤的资源不变。 2.健壮性降低 编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者因共享了不该共享的变量⽽造成不良影响的可能性是很⼤的,换句话说线程之间是缺乏保护的。 3.缺乏访问控制 进程是访问控制的基本粒度,在⼀个线程中调⽤某些OS函数会对整个进程造成影响。 4.编程难度提⾼ 编写与调试⼀个多线程程序⽐单线程程序困难得多
这里的 健壮性我们可以认为是独立性,我们知道进程之间是存在独立性的,也正是因为OS严格保证了进程之间的独立性,所以进程之间通信是那么的困难,那么的麻烦。
而线程之间这种关系没有那么紧凑, 一个线程是可以通过某种方式直接访问另一个线程内部的数据的。
以上就是告别进程的单打独斗,迈入线程的高效协同:一文带你推开Linux线程世界的大门的全部内容。