0. 背景
组里最近在招人,HH贼喜欢问人家"进程和线程有什么区别",HH问别人就算了,他还要跑来问我,我张嘴就是八股文进程是资源分配的最小单位,线程是CPU调度的最小单位巴拉巴拉
,HH笑了摇摇头,说我功力不够,给我整蒙了.
为了争口气,咱也要把这个进程和线程到底咋回事给整明白了,至少这个问题下次咱能给他掰扯掰扯.这篇博文主要是从资源的角度来回答进程和线程的问题,更复杂的调度/并行/场景之后再细聊,先挖个坑.
1. 冯诺依曼结构
冯诺依曼结构(Von Neumann architecture):是一种将程序指令存储器和数据存储器合并在一起的电脑设计概念结构。
冯诺依曼结构的计算机在体系结构上主要特点有:
- 以运算单元为中心
- 采用存储程序原理
- 存储器是按地址访问、线性编址的空间
- 控制流由指令流产生
- 指令由操作码和地址码组成
- 数据以二进制编码
将CPU与存储器分开并非十全十美,反而会导致所谓的冯·诺伊曼瓶颈(von Neumann bottleneck):在CPU与存储器之间的流量(资料传输率)与存储器的容量相比起来相当小,在现代电脑中,流量与CPU的工作效率相比之下非常小,在某些情况下(当CPU需要在巨大的资料上运行一些简单指令时),资料流量就成了整体效率非常严重的限制。
CPU将会在资料输入或输出存储器时闲置。由于CPU速度远大于存储器读写速率,因此瓶颈问题越来越严重。
2. 看看cpu/cache/内存的速度
(1) cpu的速度有多快
cpu频率的概念:
简单地说
就是每秒钟cpu时钟发生(脉冲)的数量.而时钟是cpu执行指令的最小单位.可以理解为时钟每秒钟的时钟脉冲越多,cpu就能执行越多的指令.
详细地说
在CPU这个复杂的数字系统中,为了确保内部所有硬件单元能够协同快速工作,CPU架构工程师们往往会设计一套时钟信号与系统同步进行操作。时钟信号是由一系列的脉冲信号构成,并且总是按一定电压幅度、时间间隔连续发出的方波信号,它周期性地在0与1之间往复变化。
在第一脉冲和第二个脉冲之间的时间间隔称之为周期,它的单位是秒(s)。但单位时间1s内所产生的脉冲个数称之为频率,频率的最基本计量单位就是赫兹Hz。
时钟频率(f)与周期(T)两者互为倒数:f=1/T
这个公式表明的就是频率表示时钟在1秒钟内重复的次数,而目前的CPU普遍已经处于GHz级,也就是说每秒钟产生10亿个脉冲信号。
举个例子
以Intel Core i3-8350k为例,它的默频是4GHz,意味着它内部时钟频率为4GHz,一秒钟可以产生40亿个脉冲信号,换句话说每一个脉冲信号仅仅用时0.25ns(时钟周期)。这是多么令人震惊的时钟,可以想象到CPU内部结构是多么精妙,可以处理如此之短的信号,整套系统协同有序地运行,所以才会说CPU是全人类智慧的结晶,极大地提升了我们的科技水平进步。
时钟周期作为CPU操作的最小时间单位,内部的所有操作都是以这个时钟周期作为基准。一般来说CPU都是以时钟脉冲的上升沿作为执行指令的基准,频率越高,CPU执行的指令数越多,工作速度越快。
详细看看现在的cpu速度有多快(来源wiki)
运算速度:运算速度是衡量计算机性能的一项重要指标。通常所说的计算机运算速度(平均运算速度),单字长定点指令平均执行速度MIPS(Million Instructions Per Second)的缩写,每秒处理的百万级的机器语言指令数。这是衡量CPU速度的一个指标。像是一个Intel80386 电脑可以每秒处理3百万到5百万机器语言指令,即我们可以说80386是3到5MIPS的CPU。MIPS只是衡量CPU性能的指标。是指每秒钟所能执行的指令条数,一般用“百万条指令/ 秒”来描述。微机一般采用主频来描述运算速度,主频越高,运算速度就越快。
Millions of instructions per second (MIPS):每秒百万指令数量
Processor / System | Dhrystone MIPS / MIPS | Year |
---|---|---|
Intel Core i7 4770K | 133,740 MIPS at 3.9 GHz | 2013 |
Intel Core i7 5960X | 298,190 MIPS at 3.5 GHz | 2014 |
Raspberry Pi 2 | 4,744 MIPS at 1.0 GHz | 2014 |
Intel Core i7 6950X | 320,440 MIPS at 3.5 GHz | 2016 |
ARM Cortex A73 (4-core) | 71,120 MIPS at 2.8 GHz | 2016 |
AMD Ryzen 7 1800X | 304,510 MIPS at 3.7 GHz | 2017 |
Intel Core i7-8086K | 221,720 MIPS at 5.0 GHz | 2018 |
Intel Core i9-9900K | 412,090 MIPS at 4.7 GHz | 2018 |
AMD Ryzen 9 3950X | 749,070 MIPS at 4.6 GHz | 2019 |
AMD Ryzen Threadripper 3990X | 2,356,230 MIPS at 4.35 GHz | 2020 |
AMD最新的线程撕裂者3990x的MIPS已经达到了2356230,得益于指令级并发和指令流水,指令的执行速度已经达到了恐怖的级别.
简单的计算一下就能得到:每ns3990x可以执行235条指令.
2356230*106/109 = 235/ns
(2)cache的速度有多快
cache是什么
wiki:CPU高速缓存(CPU Cache)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。
cache有多快
来源:https://blinkfox.github.io/2018/11/18/ruan-jian-gong-ju/cpu-duo-ji-huan-cun/
从CPU到 | 大约需要的CPU周期 | 大约需要的时间(单位ns) |
---|---|---|
寄存器 | 1 cycle | |
L1 Cache | ~3-4 cycles | ~0.5-1 ns |
L2 Cache | ~10-20 cycles | ~3-7 ns |
L3 Cache | ~40-45 cycles | ~15 ns |
跨槽传输 | ~20 ns | |
内存 | ~120-240 cycles | ~60-120ns |
cache为什么这么快
- 材质好,做的小,电信号跑的快
- 三级缓存,分级存储,保证命中率
- 缓存策略
(3)内存的速度有多快
我们都知道一般的pc内存(ddr3/ddr4)频率是千MHz级别的,这就意味这读取内存的时间比cpu的运行时间高10^3数量级.
(4)小结一下
我想说的已经很明显了
事实:
- 在现在的硬件环境下,cpu的处理速度是内存的处理速度的千倍数量级;
- 大量的数据和指令存储于内存当中;
推论:
如果cpu执行从内存中取数据必须同步的等待取数据的结果(甚至是从更慢的外部设备/硬盘中读取数据),将造成cpu超长时间的等待,极大地浪费了cpu的性能,整个计算的运行速度瓶颈会出现内存速度上.
方向:
- 加快内存的速度–>已经在加油了,但是必须考虑成本,内存目前是性价比最高的存储介质
- 加入缓存–>已经加入的缓存,但是内存的读取仍然不能忽视
- 并发执行指令,在等待数据的时间执行其他指令----------->进程和线程
3. 正式切入主题:进程和线程
(1)进程
I. 进程是什么
WIKI
wiki:https://zh.wikipedia.org/wiki/%E8%A1%8C%E7%A8%8B
进程(英语:process),是指计算机中已运行的程序。进程曾经是分时系统的基本运作单位。在面向进程设计的系统(如早期的UNIX,Linux 2.4及更早的版本)中,进程是程序的基本执行实体;在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。
操作系统虚拟化/抽象
操作系统为正在运行的程序提供的抽象,就是所谓的进程.
进而我们可以理解为:进程不是抽象的逻辑概念,而在操作系统中运行的程序的概括,包含了必须的操作系统资源,处于动态的变化过程中.
II. 进程拥有的资源
简单来说一个进程会包含以下四种资源,或者说有着四种信息就可以构成一个进程:
- 代码拷贝(二进制可执行机器码)
- 一块内存(包含可执行代码,堆,栈,调用堆栈)
- 系统权限(对文件的访问权限,内存的访问权限)
- 处理器状态
详细的说:
那个程序的可执行机器代码的一个在存储器的映像。
分配到的存储器(通常是虚拟的一个存储器区域)。存储器的内容包括可执行代码、特定于进程的资料(输入、输出)、调用堆栈、堆栈(用于保存运行时运输中途产生的资料)。
分配给该进程的资源的操作系统描述符,诸如文件描述符(Unix术语)或文件句柄(Windows)、资料源和资料终端。
安全特性,诸如进程拥有者和进程的权限集(可以容许的操作)。
处理器状态(内文),诸如寄存器内容、物理存储器寻址等。当进程正在运行时,状态通常存储在寄存器,其他情况在存储器。
(多说一句,我个人的理解里,进程最关键的事情就是cpu计算资源和可执行机器代码,其他的都是辅助.毕竟计算机的核心就是执行机器码.为了扩展机器的效率和复用性才引入了包括权限/内存/状态这些概念.想想最早的机器,人都是执行逻辑和机器做在一起的,说到底计算机的本质就是用计算单元+计算逻辑)
III. 进程的状态
进程的状态依赖于两个关键因素,一是是否被cup调度,一是自身是否被阻塞.
- 正在享用cpu的进程就处于运行状态,运行状态就是进程的指令被cpu执行;
- 当他需要发起io(比如读取磁盘)时,状态转换为阻塞,阻塞就是需要等待其他事件完成;
- 终于等到自己的io完成了,就可以去cpu那里排队了,进入就绪状态,就绪状态就是排队等cpu资源.
但是为什么需要这几个状态呢?当然是想要提高下cpu的使用效率(想想本文的第一部分),如果机器就执行一个进程也就犯不上调度了,更不用谈状态.
两个关键api
-
fork()
获得一个和调用进程(父进程)一样的进程(子进程),虽说是一样,子进程还是有自己的地址空间/寄存器/程序计数器.
-
exec()
如果操作系统只提供了fork,那整个系统也就只能有一个进程和他的无数拷贝了,这显然不合理.
调用exec()会从可执行程序中加载代码和静态数据,并用它复写自己的代码段(以及静态数据),堆/栈以及其他内存空间也会被重新初始化,之后操作系统就会执行这个程序.
IV. 内核态和用户态
进程这个概念出现就是为了多进程系统,我们写的代码都是用户进程,但是fork/exec/文件读写等等操作用户进程是没有权限的,是由操作系统(说到底他也就是个程序)执行的.这里有两个有趣的问题:
-
操作系统一个什么东西?
操作系统不是一个进程(我自己的曾经认为操作系统是个进程,不过有超级权限,不辞辛劳地当着大管家),而是一堆载内存中等待被待调用的代码.被调用的时机是靠中断或是异常.
当cpu上跑着用户进程时,就没操作系统什么事;当发生中断时,比如时钟到了,触发中断(内中断),调用操作系统调度函数,它检查下队列里有没有要执行的任务,执行调度逻辑,活干完了就把cpu还给被调度的程序;或是io触发(比如键盘按键)中断(外中断),执行的键盘输入中断处理程序,也是操作系统出来干活.
综合上面的内容,我们可以知道操作系统有一大堆函数,相较于用户程序,操作系统关联(触发条件)的是硬件和系统级别的事件,这部分内容是对用户进程隔离的.操作系统不是一个进程,而是被触发时表现的像一个进程,不同于用户进程的是触发事件和权限.
-
操作系统调用为什么可以像我自己写的函数一样调用?
这个问题的答案就是,操作系统调用就是一个过程调用.通过trap table(陷阱表)可以对中断和系统调用进行匹配.
讲了这么多回到用户态和内核态的问题上,其实逻辑已经呼之欲出了.
内核态/用户态(wiki)
在处理器的存储保护中,核心态,或者特权态,中国大陆称之为内核态(与之相对应的是用户态),是操作系统内核所运行的模式。运行在该模式的代码,可以无限制地对系统存储、外部设备进行访问。
微核操作系统基于安全与优雅的考虑,试图将运行在特权态的代码数量最小化。
x86结构很特别地具有四种特权等级,特权级别最高的是ring 0,被视作核心态;级别最低的是ring 3,常被看作用户态;rings 1 and 2则很少被使用。
V. 上下文切换
进程切换时的必要操作是上下文切换,保存上文是为了再次被调度时可以恢复现场,宛如没有切换过一样;切换下文是为了提供当前进程的运行现场.
问题1: 切换上下文切换的是什么
通用寄存器/程序计数器/当前进程的内核栈指针
问题2: 怎么进行一次切换
进程A执行–>时钟中断–>进程A寄存器保存到内核栈(硬件隐式保存)–>调用系统中断switch(),A的寄存器保存到A的用户栈,从B的用户栈恢复到寄存器中(OS显式进行上下文切换)–>从内核栈(B)恢复寄存器B(硬件隐式恢复)–>调到B的程序计数器–>进程B执行
这里涉及到两类的寄存器保存,一是内核栈保存,一是用户栈保存.用户栈位于用户地址空间;内核栈位于内核空间。当进程在用户地址空间中执行的时候,使用的是用户栈,CPU堆栈指针寄存器中存的是用户栈的地址;同理,当进程在内核空间执行时,CPU堆栈指针寄存器中放的是内核栈的地址。
VI. 小结一下
-
操作系统想要多多地利用cpu,但是执行各种io,网络操作时太费时间了,cpu利用率完全提不起来,所以整了一出多进程在系统里跑;
-
因为系统里有了不止一个进程,所以必须进行资源的隔离,要不就乱了套了,因此每个进程都有属于自己的一套运行需要的资源;
-
系统里有了不止一个进程,但是一颗cpu同一时刻只能运行一个进程,那其他的进程在干啥呢?所以需要给进程一个合理的调度状态,该享受享受,该等待等待;
-
一个进程该干的干的差不多了,或者要等io了,这时候要换下一个进程登场,上一个进程的事得了了,下一个进程要给加载到cpu上.
-
操作系统通过这一套逻辑实现了一个重要的概念:并发.营造了一个假象:计算机在同时运行多个程序.
(2)线程
在理解了什么是进程之后,理解线程就是小菜一碟,因为我们已经做了足够多的铺垫.
I. 为什么要线程
进程已经能够提高的cpu的利用率了,为什么还需要线程的概念呢?
可能的答案是
-
我们终于实现了多cpu的硬件架构.多核意味着真正的并行是可以行的了.如果你想一个进程把4个核都利用上,用多线程是必须的.(主要原因)
-
进程太重了,占了太多的系统资源,创建进程/进程切换都有着开销,有些场景下不需要进行上下文切换,但是也想执行不同的逻辑/控制流.(次要原因)
(单核cpu上用多线程也不能提高什么效率)
II. 线程是什么
WIKI
线程(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。
线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如Win32线程;由用户进程自行调度的用户线程,如Linux平台的POSIX Thread;或者由内核与用户进程,如Windows 7的线程,进行混合调度。
同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。
一个进程可以有很多线程来处理,每条线程并行执行不同的任务。如果进程要完成的任务很多,这样需很多线程,也要调用很多核心,在多核或多CPU,或支持Hyper-threading的CPU上使用多线程程序设计的好处是显而易见的,即提高了程序的执行吞吐率。以人工作的样子想像,核心相当于人,人越多则能同时处理的事情越多,而线程相当于手,手越多则工作效率越高。在单CPU单核的计算机上,使用多线程技术,也可以把进程中负责I/O处理、人机交互而常被阻塞的部分与密集计算的部分分开来执行,编写专门的workhorse线程执行密集计算,虽然多任务比不上多核,但因为具备多线程的能力,从而提高了程序的执行效率。
说人话
进程中的一个控制流.
III. 进程拥有的资源
如果某些资源不独享会导致线程运行错误,则该资源就由每个线程独享,而其他资源都由进程里面的所有线程共享。
1. 共享的资源
- 地址空间
- 全局变量
- 程序代码
- 堆
- 文件描述符
- …
2. 独享的资源(可能导致)
- 程序计数器(代码入口肯定不同)
- 寄存器(cpu现场肯定不同)
- 栈(线程上的变量/参数/返回值等肯定不同)
- 状态字(不同线程的状态不同)
IV. 线程的状态
坦白的讲,在linux里,进程和线程都是用task_struct
进行表达的,因此他们的状态是一样的.
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
如果真的想看task_struct
还是去看源码,这里简单的列一下.
struct task_struct {
volatile long state; //说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; //Flage 是进程号,在调用fork()时给出
int sigpending; //进程上是否有待处理的信号
mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
//0-0xBFFFFFFF for user-thead
//0-0xFFFFFFFF for kernel-thread
//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
volatile long need_resched;
int lock_depth; //锁深度
long nice; //进程的基本时间片
//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
unsigned long policy;
struct mm_struct *mm; //进程内存管理信息
int processor;
//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
unsigned long cpus_runnable, cpus_allowed;
struct list_head run_list; //指向运行队列的指针
unsigned long sleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止时向子进程发送的信号
unsigned long personality;
//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
int did_exec:1;
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //表示进程是否为会话主管
struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
struct list_head thread_group; //线程链表
struct task_struct *pidhash_next; //用于将进程链入HASH表
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion *vfork_done; //供vfork() 使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值
//it_real_value,it_real_incr用于REAL定时器,单位为jiffies, 系统根据it_real_value
//设置定时器的第一个终止时间. 在定时器到期时,向进程发送SIGALRM信号,同时根据
//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。
//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送
//信号SIGPROF,并根据it_prof_incr重置时间.
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种
//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据
//it_virt_incr重置初值。
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
struct timer_list real_timer; //指向实时定时器的指针
struct tms times; //记录进程消耗的时间
unsigned long start_time; //进程创建的时间
//记录进程在每个CPU上所消耗的用户态时间和核心态时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数); nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1; //表示进程的虚拟地址空间是否允许换出
//进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
//euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件
//系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups; //记录进程在多少个用户组中
gid_t groups[NGROUPS]; //记录进程所在的组
//进程的权能,分别是有效位集合,继承位集合,允许位集合
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
int keep_capabilities:1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned short used_math; //是否使用FPU
char comm[16]; //进程正在运行的可执行文件名
//文件系统信息
int link_count, total_link_count;
//NULL if no tty 进程所在的控制终端,如果不需要控制终端,则该指针为空
struct tty_struct *tty;
unsigned int locks;
//进程间通信信息
struct sem_undo *semundo; //进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
//进程的CPU状态,切换时,要保存到停止进程的task_struct中
struct thread_struct thread;
//文件系统信息
struct fs_struct *fs;
//打开文件信息
struct files_struct *files;
//信号处理函数
spinlock_t sigmask_lock;
struct signal_struct *sig; //信号处理函数
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;
spinlock_t alloc_lock;
void *journal_info;
};
4. 回到课文
课文里说:进程是资源分配的最小单位,线程是CPU调度的最小单位,我码了这么多字就是想解释下到底为什么课文要这么说,为什么可以这么说.
以前看这句话觉得贼抽象,现在看觉得贼亲切,总结的真好.
最后总结一下全文:
操作系统通过进程的概念实现了系统资源的封装(cpu/内存/文件等),一个进程就好像真的拥有这么资源(当然是假的),所以进程是资源分配的最小单位.
每个线程都可以单独在cpu上执行,在多核环境下,多线程实现并发成为可能,所以线程是cpu调度的最小单位.
下次接着聊多进程和多线程会遇到什么问题和使用场景,暂时起名进程和线程到底是咋回事(应用篇)
5. 参考资料
- https://en.wikipedia.org/
- https://www.zhihu.com/
- 操作系统导论-Remzi Andrea