本文以Go语言为基础

进程

基本概念

将编译好的的二进制程序,在系统中执行起来的程序会被CPU装载到内存中,CPU会按照程序的指令去顺序执行程序,这个执行中的程序被称之为进程,多个程序同时执行则被称为多进程

单核CPU在执行任务过程中不能同时执行多余的任务,但是我们在实际场景中往往可以看到一台电脑有非常多的进程在运行,这就引出了进程的中断,当程序在读写硬盘这种慢IO操作时,当前进程会发出中断指令,使得CPU去执行下一个进程,防止了阻塞等待的问题,而CPU的执行效率是非常快的,所以更多时候即使他是串行执行程序,但也可以完美接收并发任务

o8RNHH.png
o8RNHH.png

值得注意的是,并发和并行并非一样的东西,并发是代表一个CPU核心同时处理多个任务的情况,而并行是多个CPU核心处理多个任务

o8RSBQ.png
o8RSBQ.png

当进程中有大量硬盘IO操作时,将会出现大量的进程阻塞行为,而这种情况可能会占用大量的物理内存,在虚拟内存中,则会将这种阻塞的进程物理内存地址转移至硬盘地址,直至阻塞结束后再替换回物理内存中

而这种进程的执行过程也可以分为 创建、终止、阻塞、唤醒 的过程

线程

基本概念

一个进程可以有一个或多个线程

个人认为 ~ 线程是进程的抽象化,当一个进程只有一个线程时,那么这个线程就是进程

线程与进程,进程容纳了所有的资源,内存文件及各种单位,线程则只有寄存器和栈,线程与进程相同,同样有 阻塞和唤醒。

线程只有寄存器和栈的特性,以至于创建一个线程比创建进程更方便快捷,占用资源更少,当然前提是这些线程之间的共享资源大部分相同

进程占用资源,线程执行过程,线程都是依托于进程的,线程之间可以共享进程的资源

三种形式

  • 用户线程 (User Thread)
  • 内核线程 (Kennel Thread)
  • 轻量线程 (LightWeight Thread)

首先要明确一个事实,他们并不是同一个维度的,多个用户线程对应多个内核线程,也就是他们是多对多的关系(协程则相当于一个内核线程对应了多个用户线程)

oG86l6.png
oG86l6.png

通常我们常说的线程大部分指的是内核线程 ~

用户线程是由用户发起的调用,操作系统无法干预,当用户线程执行过程中,操作系统无法查看当前线程的情况也无法由操作系统关闭,即任务管理器也没用(协程则可以通过 pprof 之类的debug工具去测试)

用户线程相当于用户自发开启的线程,不通过系统内核,也可以认为启动用户线程的速度要比内核线程要快的多,也因为如此,即使是不支持线程的系统,也能由用户自发开启的线程来开启用户线程无需兼容内核

内核线程则是通过操作系统调用的,可以通过操作系统去管理该线程,相比于用户线程,内核线程的资源分配更加合理,且用户线程可能会因为阻塞导致多个线程同时阻塞,而内核线程的调度则由操作系统直接完成,无需考虑这一问题,但相比于用户线程,显然内核线程要更重量级一些

轻量线程有多重解释,它相当于内核线程与用户线程的对应和结合,相当于用户线程与内核线程的桥梁或者中间层,与用户线程也是,多对多的关系,兼容三种模式 1:1 N:1 N:N

1:1 一个用户线程 对应一个轻量线程 对应一个内核线程
N:1 多个用户线程对应一个轻量线程 对应一个内核线程
N:N 混合模式,前面两种方式的结合,多个用户线程对应多个轻量线程,由轻量线程找到内核线程一一对应

调度

调度模型

进程调度,当出现IO请求的时候,调度会将当前执行的进程切换到另一个进程,并保留上下文,以便于稍后切换回来时能够继续执行之前的进程

进程调度,当出现长时间占用CPU的进程时,造成系统吞吐量降低,调度会权衡任务完成数量

如果一个进程本身计算不复杂,而占用时间长时,需要考虑是否是IO的调用导致的,频繁的IO请求会增加CPU的进程切换,也会加大程序的执行时长

调度对于鼠标键盘等需要实时响应的程序会优先考虑

进程如果处于就绪状态,调度会尽快让进程继续执行

进程之间会通过内核的时间分片去模拟出并发场景,当单核CPU出现三个进程时,如果都没有阻塞,则调度会通过时间分片的方式先完成简单的程序然后再完成复杂程序,ABC三个进程同时存在时,按照进程顺序, A B C,内核会分配一个固定时间分片,例如 5ns,A执行5ns后执行B,B执行5ns后执行C,假设进程B执行完成时,则调度会继续A C之间的循环,B完成后执行C,C执行5ns后执行A,然后循环执行A C

如上操作会出现一个问题就是内核会频繁的切换进程,我们知道上下文也是消耗资源的,那么内核会做一个递增时间分片的操作,例如一开始是5ns,后续可能就递增为 10ns 15ns,以便于尽早完成一个进程减少进程上下文

oaUUtx.png
oaUUtx.png

通信

管道
在Linux中我们常用的通信管道,|,如下命令中 ps -ef |grep sbcoder ,里面的符号位 | 就是一个管道,他代表将前一个进程中的数据带到下一个进程中

这种没有命名的管道被称之为 匿名管道,匿名管道将在进程结束后释放,有匿名则也有有名字的管道 命名管道 ,Linux中通过关键字 mkfifo 创建命名管道 mkfifo sbcoderChannel,通过命令可以将数据传输至管道中,echo "sbcoder" > sbcoderChannel,接着命令就卡住了,这与Go语言中的信道很类似,有阻塞的作用通过阻塞我们可以做很多事情,有入则有出,可以通过命令取出数据 cat < sbcoderChannel 获取刚才echo进去的数据

管道实际上是内核中的缓存,而读取管道数据则是在一个进程中fork出一个子进程去读取,而如果由一个进程同事负责读写则会非常混乱,所以如果需要双向通信则需要fork两个子进程,而创建子进程也会徒增负荷,也要尽可能避免此类操作

oa1uHf.png
oa1uHf.png

消息队列
消息队列是保存在内核中的消息链表,由于消息队列的特性,会出现通信不及时和大小限制问题,更适合小数据的场景

共享内存
进程A与进程B都使用虚拟内存(只保存物理地址),则可以实现同一个数据块儿被两个进程同时使用

oa38IO.png
oa38IO.png

当A和B同时操作一块儿内存时,则也会出现问题,很容易造成数据冲突,就出现了lock的概念,也被称为 信号量,A操作时将信号量数值更改,B同时操作时发现数值已经被更改则说明资源被占用,进程阻塞,A在操作完成后会将信号量数值再改回去,B此时即可继续执行

oa89YD.png
oa89YD.png

信号Signal

信号,也就是Signal,通过进程中的各种指令做通信,例如:程序接收到快捷键 ctrl + c 则会终止程序

Go语言中的Signal同理~

socket

不同主机之间的socket通信,为了方便不同主机之间的进程通信,衍生出的通信方式,他可以借助 TCP以及UDP以及本地进程等方式通信,其中TCP和UDP更适用于远程主机通信,而本地通信可以借助本地进程通信(字节流,数据报)

思考

  • 高并发场景是否应该频繁读取硬盘信息呢

个人感觉可以通过一个大文本读取至内存,然后并发处理的过程中将会很少产生阻塞行为,但也属于物理内存充足的情况下(空间换时间)

  • 如何更好的利用进程特性处理高并发任务

我们了解并发实际也是自上至下的串行执行程序,那么也要适当地减少并发任务的复杂度和频繁的IO操作,但合理运用内存很重要,如果能计算出什么时间内物理内存的占用情况则更合理一些

  • 单内核多进程调度场景,如何设计更适合的程序结构

个人认为,应该尽可能减少进程数量,在用户态之间搞定,类似Go的GMP调度,如果进程数量实在无法减少可以尝试将某些轻量级程序减负,让重量级的程序保持,多应用消息队列的方式进行通信(过多使用消息队列也是负担),需要综合考量异步的优缺点