1 物理 CPU 数量
电脑拥有的物理CPU数量,普通电脑一般只有一个CPU插槽,也就是只有一个物理CPU。我们日常说的CPU,就是指封装好的一个物理CPU,作为商品进行售卖。但在编程讨论时,某些情况下,我们说的CPU含义又是指其中一个运算单元,即下面的说的【CORE核心】。
2 CPU CORE核心数(物理核心数)
核心数为一个物理CPU拥有的运算单元数量,例如8核,即一个CPU有8个核心,拥有8个独立的运算单元。一个运算单元有一套寄存器,一般还有L1,L2的私有缓存,可以独立执行一个任务(一般是线程),所以一个核心同一时刻只能运行一个线程,不论这个线程是哪个进程的。每隔一定时间,大概几十毫秒,就会切换线程,即切换任务(可能是同一个进程的线程,也可能是另外一个进程的线程,如果是其它进程的线程,会切换CR3)。
2.1 切换 CR3
每个进程都会有自己的一张分级的页表,用于查找该进程所访问到的物理内存(因为进程都是独立的,物理内存肯定是限制访问)。当操作系统执行进程切换的时候,会将执行该进程页表的那个指针加载到CR3寄存器,这样当进程切换后,CPU访问的内存就是属于该进程的内存或者内核所使用的内存。进程对应的页表指针是由内核进行维护的。
当核心被切换到别的线程,并且这个线程属于别的进程,CR3必须更新为该线程所属的进程页表的那个指针,所以会切换CR3。我只简单理解为CR3用来标记线程所属的进程。
3 CPU THREAD线程数(逻辑核心数)
一般来说,一个物理核心就是一个独立的运算单元,一个物理核心(运算单元)同时最多运行一个线程,也就是一个线程数。但是英特尔的超线程技术,可以实现一个核心同时执行多个线程,则产生了逻辑核心数的说法:就是我们说的8核16线程,一个核心能跑二个线程,核心数是8,线程数就是16。
3.1 超线程
CPU拥有大量资源,并可以预先运行及平行运行指令,以增加指令运行效率,可是在现实中这些资源经常闲置,为了有效利用这些资源,就干脆再增加一些资源来运行第二个线程让这些闲置资源可执行另一个线程,而且CPU只要增加少数资源就可以模拟成两个线程运作,因此它可以实现真正的并行。
CPU在执行一条机器指令时,并不会完全地利用所有的CPU资源,而且实际上,是有大量资源被闲置着的。超线程技术允许两个线程同时不冲突地使用CPU中的资源。比如一条整数运算指令只会用到整数运算单元,此时浮点运算单元就空闲了,若使用了超线程技术,且另一个线程刚好此时要执行一个浮点运算指令,CPU就允许属于两个不同线程的整数运算指令和浮点运算指令同时执行,这是真正的并行。
超线程可能是并行的,也可能不是并行的,这意味着两个线程在同一个CPU(也就是同一个核心)中一直都可以并行执行,只是恰好碰到两个线程当前要执行的指令不使用相同的CPU资源时才可以真正地并行执行。
以上的CPU,就是指其中一个CORE核心
4 PROCESS进程
操作系统分配资源的最小单位(包括CPU『这里的CPU指CPU核心』、内存、磁盘IO等)。一个进程可以拥有多个线程。
5 THREAD 线程
线程是CPU调度和分配的基本单位。一个进程下的线程共享该进程的资源和程序代码。
6 并行和并发
吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这说明你不支持并发也不支持并行。
吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
- 并行,关键是有处理多个任务的能力,不一定要同时。
- 并发,关键是有同时处理多个任务的能力。
7 单核CPU和多核CPU
对于单核CPU,多个进程在单核CPU是并发运行,根据时间片读取上下文+执行程序+保存上下文。对于多核CPU,多个进程在多核CPU是并行运行,进程能在多个核中运行。当然这时候单个核心也会存在进程切换。单核CPU中进程只能是并发,多核CPU中进程可以并行。
对于单核CPU,多个线程在单核CPU是并发运行。对于多核CPU,多个线程在多核CPU是并行运行,线程能在多个核中运行。单核CPU中线程只能是并发,多核CPU中线程可以并行。
上面说的单核CPU和多核CPU,又是指物理CPU;单核和多核指的是CORE核心数
7.1 线程是CPU调度和分配的基本单位
CPU核心只能看到线程。对于 CORE核心,一直等待进程调度,操作系统调度器将一个进程分配给CORE核心之后,CORE核心拿到进程(分配到一个进程)之后开始执行任务。CORE核心看到的是进程中的很多线程和分配给CORE核心的资源。
CORE核心只能调度CORE核心看到的那些线程和资源,如果CPU核心是4核的话,把线程ABCD分配到核心1234,其他的线程依然要等待分配。
线程才是真正执行的单位,而执行任务的是CORE核心。于是有线程是CPU调度和分配的基本单位的说法。也解释不论这个线程是哪个进程的,CPU眼里没有进程的概念这个说法。
这里说的CORE核心被分配到一个进程,可以理解为CR3被切换到了一个新的进程,有了新的上下文,此时对于CORE核心来说,一般开始执行这个进程的主线程了。亦或者说操作系统调度了别的进程,开始执行新线程。
7.2 进程是操作系统进行资源分配的最小单位
计算机资源是多种多样的(包括CPU『这里的CPU指CORE核心』、内存、磁盘IO等),操作系统在分配这些资源的时候,以进程为单位进行分配。操作系统调度的也是进程。
7.3 属于同个进程下的多个线程,是跑在不同的核上还是只能跑在同个核上?
在大多数支持多核的操作系统上,都能实现把一个进程的多个线程放不同的核心上跑。一个进程可能由多个线程组成,而线程的实现有两种方式,一种是内核调度的线程,可以运行在多个核上,还有一种是用户级线程,这个就没办法跑到多个核上了。
8 COROUTINE协程
要搞清楚协程,先明白用户级线程和内核级线程,以及用户态和内核态。
- User Mode用户态,运行用户程序,只能操作被分配的内存空间和资源,权限很小
- Kernel Mode内核态,运行操作系统程序,操作硬件,权限很大
- 用户级线程,用户级线程是指不需要内核支持而在用户程序中实现的线程,它的内核的切换是由用户态程序自己控制内核的切换,不需要内核的干涉。但是它不能像内核级线程一样更好的运用多核CPU。
- 内核级线程,内核级线程是指切换由内核控制的线程,当线程进行切换的时候,由用户态转化为内核,切换完毕要从内核态返回用户态。
8.1 用户级线程
线程的概念出现了,但操作系统厂商可不能直接就去修改操作系统的内核,因为对他们来说,稳定性是最重要的。贸然把未经验证的东西加入内核,具有极大的风险。因此想要验证线程的可用性,得另想办法。
研究人员编写了一个关于线程的函数库,用函数库来实现线程。把创建线程、终止线程等功能放在了这个线程库内,用户就可以通过调用这些函数来实现所需要的功能。这个线程库,是位于用户空间的,操作系统内核对这个库一无所知,对我们通过线程库创建的线程也一无所知。也就是说操作系统眼里还是只有这一个进程。
用线程库写的一个多线程进程,多个线程也就都只能在一个CPU核心上运行,无法运行在多核心上。这其实是用户级线程的一个缺点,这些线程只能占用一个核,做不到并行加速,而且由于用户线程的透明性,操作系统是不能主动切换线程的。
简单理解为:用户态线程是寄生在某个内核线程或者说寄生在某个进程上的,对于操作系统来说,它依然只是一个线程,当然只能运行在一个CORE核心上。这就解释了为什么用户级线程不能跑在多个核上。
这里称为寄生不正确,但好描述,易理解。
8.2 用户级线程的弊端
如果A,B是同一个进程的两个线程的话,A正在运行的时候,线程B想要运行的话,只能等待A主动放弃CPU。一个用户级线程的阻塞将会引起整个进程的阻塞,用户级线程执行系统调用指令时将导致其所属进程被中断,而内核级线程执行系统调用指令时,只导致该线程被中断,因为其他线程依然能跑在不同的核心上。即使有线程库,用户级线程也做不到像内核级线程那样的轮转调度。
原因是一个用户级线程阻塞了,在操作系统眼里,是进程阻塞了,那么整个进程就会进入阻塞态,在阻塞操作结束前,这个进程都无法得到CPU资源。那就相当于所有的用户级线程都被阻塞了。所以如果任由线程进行阻塞操作,进程的效率将受到很大的影响
用户级线程也有他自己的好处:你可以为你的应用程序定制调度算法,什么时候退出线程你自己说了算。这时候出现了一个办法:就是把一个产生阻塞的系统调用转化成一个非阻塞的系统调用。用户级线程再调用I/O,不是直接调用一个系统 I/O 例程,而是调用一个应用级别的 I/O 例程,这个例程中的代码会检查并且确定 I/O 设备是不是正忙。如果忙的话,就在用户态下将该线程阻塞,然后把控制权交给另一个线程。隔一段时间后再次检查 I/O 设备。虽然最后还是会执行阻塞调用,但使用该方法可以缩短被阻塞的时间。不过有些情况下是可以不被阻塞的,取决于具体的实现。
在这个过程中,对于操作系统来说,这个内核线程或者说进程依然是执行的,不会被挂起。但是对于寄生在内核线程或者说进程上的用户级线程来说,它本身是阻塞了。在它阻塞时,把CORE核心让给其他用户级线程了,这个用户态线程肯定是属于同个进程的。
我们可以创建非常多的内核线程,但是当线程数量非常多的时候,会出现问题。一是系统线程会占用非常多的内存空间,二是过多的线程切换会占用大量的系统时间。协程刚好可以解决上述两个问题。协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行多个协程,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多。
一个进程有多个线程。线程可能分为内核态线程和用户态线程。
8.3 内核态线程
优点:一个进程的多个线程可以分配到多个CORE核心同时执行,并且由于内核级线程只有很小的数据结构和堆栈,切换速度快,本身也可以用多线程技术实现提高系统的运行速率。
缺点:线程在用户态的运行,而线程的调度和管理在内核实现,在控制权从一个线程传送到另一个线程需要用户态到内核态再到用户态的模式切换,比较占用系统资源。
8.4 用户态线程
优点:线程的调度不需要内核直接参与,控制简单,可以在不支持线程的操作系统中实现
缺点:没有办法使用多核心并行执行
9 内核线程和普通进程
9.1 Linux 中的进程和线程
对于 Linux 来讲,所有的线程都当作进程来实现,因为没有单独为线程定义特定的调度算法,也没有单独为线程定义特定的数据结构(所有的线程或进程的核心数据结构都是 task_struct
。
对于一个进程,相当于是它含有一个线程,就是它自身。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
进程拥有自己的地址空间,所以每个进程都有自己的页表。而线程却没有,只能和其它线程共享某一个地址空间和同一份页表。
这个区别的根本原因是在 进程/线程 创建时,因是否拷贝当前进程的地址空间还是共享当前进程的地址空间,而使得指定的参数不同而导d导致的。
进程和线程的创建都是执行 clone 系统调用进行的。而 clone 系统调用会执行 do_fork 内核函数,而它则又会调用 copy_process 内核函数来完成。主要包括如下操作:
- 在调用 copy_process 的过程中,会创建并拷贝当前进程的 task_stuct,同时还会创建属于子进程的 thread_info 结构以及内核栈。
- 此后,会为创建好的 task_stuct 指定一个新的 pid(在 task_struct 结构体中)。然后根据传递给 clone 的参数标志,来选择拷贝还是共享打开的文件,文件系统信息,信号处理函数,进程地址空间等。
这就是进程和线程不一样地方的本质所在。
9.2 三个数据结构
每个进程或线程都有三个数据结构,分别是:
- struct thread_info
- struct task_struct
- 内核栈
虽然线程与主线程共享地址空间,但是线程也是有自己独立的内核栈的。
9.2.1 thread_info
thread_info 对象中存放的进程/线程的基本信息,它和这个进程/线程的内核栈存放在内核空间里的一段 2 倍页长的空间中。其中 thread_info 结构存放在低地址段的末尾,其余空间用作内核栈。内核使用伙伴系统为每个进程/线程分配这块空间。
thread_info 结构体中有一个 struct,即 task_struct *task,task 指向的就是这个进程或线程相关的 task_struct 对象(也在内核空间中),这个对象叫做进程描述符(叫做任务描述符更为贴切,因为每个线程也都有自己的 task_struct)。内核使用 slab 分配器为每个进程/线程分配这块空间。
9.2.2 task_struct
每个进程或线程都有只属于自己的 task_struct 对象,是它们各自最为核心的数据结构。
- task_struct 结构体中的主要元素:
struct thread_info *thread_info // thread_info 指向该进程/线程的基本信息
struct mm_struct *mm // mm_struct 对象用来管理该进程/线程的页表以及虚拟内存区
struct mm_struct *active_mm // 主要用于内核线程访问主内核页全局目录
struct fs_struct *fs // fs_struct 是关于文件系统的对象
struct files_struct *files // files_struct 是关于打开的文件的对象
struct signal_struct *signal // signal_struct 是关于信号的对象
- task_struct 结构体中的三个 ID 与一个指针
- pid:每个 task_struct 都会有一个不同的 ID,就是这个 PID。
- tid:线程 ID,用来标识每个线程的。
- tgid:线程组领头线程的 PID,事实上就是主线程的 PID。当创建一个子进程时,它的 tgid 与 pid 相等;当创建一个线程时,它的 tgid 等于主线程的 pid。getpid() 函数事实上返回的是当前进程或线程的 tgid。
- pgid:进程组领头进程的 PID。
- sid:会话领头进程的 PID。
- group_leader:是一个 task_struct 类型的指针,指向的是进程组的组长对应的 task_struct 对象。
9.3 虚拟内存地址空间
9.3.1 内存管理
内存是由内核来管理的。内存被分为 n 个页框,然后进一步组织为多个区。而装入页框中的内容称为页。当内核函数申请内存时,内核总是立即满足(因为内核完全信任它们,所以优先级最高)。在分配适当内存空间后,将其映射到内核地址空间中(对于 32 位操作系统而言是 3-4GB 中的某部分空间),然后将地址映射写入页表。
申请内存空间的内核函数有 vmalloc, kmalloc, alloc_pages, __get_free_pages 等。
9.3.2 内核常驻内存
内核地址空间(3-4GB)中的页面所映射的页框始终在物理内存中存在,不会被换出。即使是 vmalloc 动态申请的页面也会一直在物理内存中,直至通过相关内核函数释放掉。其原因在于,一方面内核文件不是太大,完全可以一次性装入物理内存;另一方面在于即使是动态申请内存空间,也能立即得到满足。因此,处于内核态的普通进程或内核线程不会因为页面没有在内存中而产生缺页异常(不过处于内核态的普通进程会因为页表项没有同步的原因而产生缺页异常)。
9.3.3 为什么要有虚拟地址空间
普通进程在申请内存空间时会被内核认为是不紧要的,优先级较低。因而总是延迟处理,在之后的某个时候才会真正为其分配物理内存空间。比如,普通进程中的 malloc 函数在申请物理内存空间时,内核不会直接为其分配页框。
另一方面,普通进程对应的可执行程序文件较大,不能够立即装入内存,而是采取运行时按需装入。要实现这种延迟分配策略,就需要引入一种新的地址空间,即虚拟地址空间。可执行文件在装入时或者进程在执行 malloc 时,内核只会为其分配适当大小的虚拟地址空间。
虚拟地址空间并不单纯地指线性地址空间。准确地说,指的是页面不能因为立即装入物理内存而采取折衷处理后拥有的线性地址空间。因此,虽然普通进程的虚拟地址空间为 4GB,但是从内核的角度来说,内核地址空间(也是线性空间)不能称为虚拟地址空间,内核线程不拥有也不需要虚拟地址空间。因此,虚拟地址空间只针对普通进程。
当然,这样的话就会产生所要访问的页面不在物理内存中而发生缺页异常。
9.3.4 虚拟地址空间的划分
每一个普通进程都拥有 4GB 的虚拟地址空间(对于 32 位的 CPU 来说)。主要分为两部分,一部分是用户空间(0-3GB),一部分是内核空间(3-4GB)。每个普通进程都有自己的用户空间,但是内核空间被所有普通进程所共享。
用户态下的普通进程只能访问 0-3GB 的用户空间;内核态下的普通进程既能访问 0-3GB 的用户空间,也能访问 3-4GB 的内核空间(内核态下的普通进程有时也会需要访问用户空间)。
9.3.5 普通线程的用户堆栈与寄存器
对于多线程环境,虽然所有线程都共享同一片虚拟地址空间,但是每个线程都有自己的用户栈空间和寄存器,而用户堆仍然是所有线程共享的。栈空间的使用是有明确限制的,栈中相邻的任意两条数据在地址上都是连续的。试想,假设多个普通线程函数都在执行递归操作。如果多个线程共有用户栈空间,由于线程是异步执行的,那么某个线程从栈中取出数据时,这条数据就很有可能是其它线程之前压入的,这就导致了冲突。所以,每个线程都应该有自己的用户栈空间。寄存器也是如此,如果共用寄存器,很可能出现使用混乱的现象。
而堆空间的使用则并没有这样明确的限制,某个线程在申请堆空间时,内核只要从堆空间中分配一块大小合适的空间给线程就行了。所以,多个线程同时执行时不会出现像栈那样产生冲突的情况,因而线程组中的所有线程共享用户堆。
那么在创建线程时,内核是怎样为每个线程分配栈空间的呢?进程/线程的创建主要是由 clone 系统调用完成的。而 clone 系统调用的参数中有一个 void *child_stack,它就是用来指向所创建的进程/线程的堆栈指针。而在该进程/线程在用户态下是通过调用 pthread_create 库函数而陷入内核的。对于 pthread_create 函数,它则会调用一个名为 pthread_allocate_stack 的函数,专门用来为所创建的线程分配的栈空间(通过 mmap 系统调用)。然后再将这个栈空间的地址传递给 clone 系统调用。这也是为什么线程组中的每个线程都有自己的栈空间。
9.3.6 普通进程的页表
有两种页表,一种是内核页表,另一种是进程页表。
普通进程使用的则是进程页表,而且每个普通进程都有自己的进程页表。如果是多线程,则这些线程共享的是主线程的进程页表。
9.3.7 四级页表
现在的 Linux 内核中采用四级页表,分别为:
- 页全局目录 (Page Global Directory, pgd);
- 页上级目录 (Page Upper Directory, pud);
- 页中间目录 (Page Middle Directory, pmd);
- 页表 (Page Table, pt)。
task_struct 中的 mm_struct 对象用于管理该进程(或者线程共享的)页表。准确地说,mm_struct 中的 pgd 指针指向着该进程的页全局目录。
9.3.8 普通进程的页全局目录
普通进程的页全局目录中,第一部分表项映射的线性地址为 0-3GB 部分,剩余部分存放的是主内核页全局目录中的所有表项。
9.3.8 内核线程
内核线程是一种只运行在内核地址空间的线程。所有的内核线程共享内核地址空间(对于 32 位系统来说,就是 3-4GB 的虚拟地址空间),所以也共享同一份内核页表。这也是为什么叫内核线程,而不叫内核进程。
由于内核线程只运行在内核地址空间中,只会访问 3-4GB 之间的 1GB 的内核地址空间,不存在虚拟地址空间,因此每个内核线程的 task_struct 对象中的 mm 为 NULL。普通线程虽然也是同主线程共享地址空间,但是它的 task_struct 对象中的 mm 不为空,指向的是主线程的 mm_struct 对象。
普通进程与内核线程有如下区别:
- 内核线程只运行在内核态,而普通进程既可以运行在内核态,也可以运行在用户态;
- 内核线程只使用 3-4GB (对于 32 位系统) 的内核地址空间(共享的),但普通进程由于既可以运行在用户态,又可以运行在内核态,因此可以使用 4GB 的虚拟地址空间。
系统在正式启动内核时,会执行 start_kernel 函数。在这个函数中,会自动创建一个进程,名为 init_task。其 PID 为 0,运行在内核态中。然后开始执行一系列初始化。
9.3.9 init 内核线程
init_task 在执行 rest_init 函数时,会执行 kernel_thread 创建 init 内核线程。它的 PID 为 1,用来完成内核空间初始化。
在内核空间完成初始化后,会调用 exceve 执行 init 可执行程序 (/sbin/init)。之后,init 内核线程变成了一个普通的进程,运行在用户空间中。
init 内核线程没有地址空间,且它的 task_struct 对象中的 mm 为 NULL。因此,执行 exceve 会使这个 mm 指向一个 mm_struct,而不会影响到 init_task 进程的地址空间。也正因为此,init 在转变为进程后,其 PID 没变,仍为 1。
创建完 init 内核线程后,init_task 进程演变为 idle 进程(PID 仍为 0)。之后,init 进程再启动其它系统进程 (/etc/init.d 目录下的各个可执行文件)。
9.3.10 kthreadd 内核线程
init_task 进程演变为 idle 进程后,idle 进程会执行 kernel_thread 来创建 kthreadd 内核线程(仍然在 rest_init 函数中)。它的 PID 为 2,用来创建并管理其它内核线程(用 kthread_create, kthread_run, kthread_stop 等内核函数)。
系统中有很多内核守护进程 (线程),可以通过:ps -efj
进行查看,其中带有 [] 号的就属于内核守护进程。它们的祖先都是这个 kthreadd 内核线程。
9.3.11 主内核页全局目录
内核维持着一组自己使用的页表,也即主内核页全局目录。当内核在初始化完成后,其存放再swapper_pg_dir 中,而且所有的普通进程和内核线程就不再使用它了。
9.3.12 内核线程如何访问页表
9.3.12.1 active_mm
对于内核线程,虽然它的 task_struct 中的 mm 为 NULL,但是它仍然需要访问内核空间,因此需要知道关于内核空间映射到物理内存的页表。然而不再使用 swapper_pg_dir,因此只能另外想法解决。由于所有的普通进程的页全局目录中的后面部分为主内核页全局目录,因此内核线程只需要使用某个普通进程的页全局目录就可以了。
在 Linux 中,task_struct 中还有一个很重要的元素为 active_mm,它主要就是用于内核线程访问主内核页全局目录。对于普通进程来说,task_struct 中的 mm 和 active_mm 指向的是同一片区域;然而对内核线程来说,task_struct 中的 mm 为 NULL,active_mm 指向的是前一个普通进程的 mm_struct 对象。
9.3.12.2 mm_users 和 mm_count
如果因为前一个普通进程退出了而导致它的 mm_struct 对象也被释放了,则内核线程就访问不到了。为此,mm_struct 对象维护了一个计数器 mm_count,专门用来对引用这个 mm_struct 对象的自身及内核线程进行计数。初始时为 1,表示普通进程本身引用了它自己的 mm_struct 对象。只有当这个引用计数为 0 时,才会真正释放这个 mm_struct 对象。
另外,mm_struct 中还定义了一个 mm_users 计数器,它主要是用来对共享地址空间的线程计数。事实上,就是这个主线程所在线程组中线程的总个数。初始时为 1。
两者在实质上都是针对引用 mm_struct 对象而设置的计数器。 不同的是,mm_count 是专门针对自身及内核线程或引用 mm_struct 而进行计数;而 mm_users 是专门针对该普通线程所在线程组的所有普通线程而进行计数。 另外,只有当 mm_count 为 0 时,才会释放 mm_struct 对象,并不会因为 mm_users 为 0 就进行释放。