对于32位的操作系统而言,它的寻址空间最大为4g(2的32次方),换言之一个系统进程最大可运行的内存地址空间为4个G,操作系统核心空间占用1g,其他3g则给予用户空间使用。针对Linux操作系统而言,最高的1G字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),由内核使用,称为内核空间,而较低的3G的字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。
为了安全,通过隔离内核空间和用户空间,使得相互之间不受影响。内核空间由操作系统所管理,负责的都是比较核心的资源管理,甚至中断等。通过环境隔离,使得操作系统的资源被保护起来,这样不会因为用户空间的用户进程滥用崩溃从而导致内核空间也跟着崩溃。
内核空间这些指令在CPU层面被称之为R0级的指令,而用户空间则运行在R3级别的指令集。操作系统也是应用了cpu的这种指令级别划分。
通过上文的分析,我们已经了解到操作系统通过空间隔离,隔离用户的程序和操作系统的程序,这就使得它们之间互不影响。从而建立操作系统程序的健壮性。但是,这种空间隔离也带来了运行程序额外的开销,即空间切换。
进程运行在内核空间就处于内核态,运行在用户空间则属于用户态,任何一个进程,有两个堆栈,在(内核态和用户各有一个堆栈),这就使得用户在发起一个系统调用或者系统中断的时候必须要暂停当前的应用程序,并且保存当前程序的上下文,并且将程序切换到内核堆栈中执行。这个切换的过程,也就是我们所说的性能开销。
用户进程通过系统函数发起一次系统调用,系统调用的机制,核心是使用了操作系统为用户开放的终端来实现,例如Linux的ine 80h中断
CPU通过持有的虚拟地址和地址总线访问链接在地址总线上的所有设备,由MMU将虚拟地址转换成物理地址,当没有创建一个虚拟地址到物理地址的映射,或创建了映射,但物理页不允许写的时候,触发的缺页异常
当外围设备处理完成某项操作之后,它会发出一个中断信号,以此来中断当前运行的用户程序,转而抢占cpu的执行权。比如硬盘的读写完成、用户的输入输出。这些都需要优先处理。否则,用户端看起来只能是操作的卡顿。
进程这个概念是在多道程序设计中引入的,在此之前的是批处理程序,所以进程主要是用来解决程序不能并发执行从而导致 CPU 利用率低下这个问题的。
进程管理,就是在程序之上抽象出了进程的概念,然后通过进程状态、上下文切换、中断、调度等等手段,最终实现使程序在多道程序环境下能并发执行,并能对并发执行的程序加以控制和描述。
进程(Processes)是程序的一次动态执行,是系统进行资源分配和调度的基本单位,是操作系统运行的基础,通常每一个进程都拥有自己独立的内存空间和系统资源。简单来说,进程可以被当做是一个正在运行的程序。
进程是程序动态运行的一个过程,是操作系统资源分配和独立运行的基本单位。其有自己独立的内存空间,不同的进程之间通过各种相对应的通信方式进行通信。但又因为进程拥有独立的内存空间,所以它的上下文切换(栈,寄存器,页表,文件句柄等)开销就比较大,但是相对来说进程的运行就比较安全稳定。
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。
常见的进程上下文切换场景:
1、为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
2、进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
3、当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
4、当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
5、发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
多线程就是把操作系统中的这种并发执行机制原理运用在一个程序中,把一个程序划分为若干个子任务,多个子任务并发执行,每一个任务就是一个线程。
实现多线程是采用一种并发执行机制。
线程又可以叫做轻量级进程,是CPU任务调度和系统执行的最小单位。
线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以包含更多的子线程。
多个线程可以共享所属的进程资源,同时线程也有自己独立的专属资源和栈空间。
线程间的通信主要通过共享内存,上下文切换很快,资源开销比较小,但相比进程而言不够稳定且容易丢失数据。
协程是属于线程的。协程程序是在线程里面跑的,因此协程又称微线程和纤程等;
协程没有线程的上下文切换消耗。协程的调度切换是用户(程序员)手动切换的,因此更加灵活,因此又叫用户空间线程.
原子操作性。由于协程是用户调度的,所以不会出现执行一半的代码片段被强制中断了,因此无需原子操作锁。
简单来说,线程粒度还不够细。举个例子,在网络服务中,调用read函数读取数据,如果socket缓冲区没有数据,当前线程就会阻塞一直到缓冲区可读才行。注意,整个线程会被阻塞,而并发性能自然会受到影响。如果能把线程更细粒度区分为很多子任务,线程在多个子任务之间交替执行。比如在子任务A里面调用read函数,如果socket不可读,那么子任务A阻塞,让出执行权,线程转而去执行其他的子任务。 当可读条件满足后,线程又唤醒子任务A,从上次read阻塞的地方恢复继续执行。可以看到,线程并没有阻塞,而是转而去执行其他任务。这对并发就进一步提高了。
另外,这里子任务简单来说就是一个函数罢了,要封装这么一个子任务也很简单,把当前函数的栈空间、寄存器状态保存下来即可。而这个子任务,其实就是协程的概念。由于它只用一些寄存器状态就可以描述,所以其实协程占用的资源非常少,要实现上万的协程是非常容易的。然而如果是上万个线程,操作系统就要骂娘了。
进程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。进程的切换内容包括页全局目录、内核栈、硬件上下文,切换内容保存在内存中。进程切换过程是由“用户态到内核态到用户态”的方式,切换效率低。
线程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户无感知。线程的切换内容包括内核栈和硬件上下文。线程切换内容保存在内核栈中。线程切换过程是由“用户态到内核态到用户态”, 切换效率中等。协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序所决定的。
协程的切换内容是硬件上下文,切换内存保存在用户自己的变量(用户栈或堆)中。协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。
这类协程的实现类似于内核态线程的实现,不同协程间切换还是要切换对应的栈上下文,只是不用陷入内核而已;例如:goroutine、libco;
无栈协程的上下文都会放到公共内存中,在协程切换时使用状态机来切换,而不用切换对应的上下文(因为都已经在堆中了),因此相比有栈协程要轻量许多;例如:C++20、Rust、JavaScript 中的协程。
这里所谓的有栈、无栈:
并不是说这个协程运行的时候有没有栈,而是说协程之间是否存在调用栈(Callback Stack);
协程不是进程,也不是线程,它就是一个可以在某个地方挂起的特殊函数,并且可以重新在挂起处继续运行。所以说,协程与进程、线程相比,不是一个维度的概念。
一个进程可以包含多个线程,一个线程也可以包含多个协程,也就是说,一个线程内可以有多个那样的特殊函数在运行。但是有一点,必须明确,一个线程内的多个协程的运行是串行的。如果有多核CPU的话,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内的多个协程却绝对串行的,无论有多少个CPU(核)。这个比较好理解,毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但是这些函数都是串行运行的。当一个协程运行时,其他协程必须挂起。
串⾏服务,服务能⼒受限
1、受限于操作系统调度能⼒,不能⽆限横向扩展
2、调度成本⾼
1、异步逻辑流拆分,维护复杂的状态机,代码难以理解、不好维护
2、Libevent、Libev、Redis、Nginx、Memcached
3、线程创建、销毁、切换相对都⽐较费
1、协程一般是基于事件驱动的异步模型的封装
2、编程语⾔或者编程框架⽀持(同步的简单+异步的性能)
3、解放⽣产⼒
在实际的业务开发中,比如针对一个业务流程,调用三方,然后存储数据,从oss上获取数据。其实都是进行的同步调用,说白了就是A完成之后,B在继续完成。如果整个过程中A、B、C 分别耗时100、300、200毫秒。那么整体的耗时就是600毫秒。
但是如果使用异步方式,而整体A、B、C又没有数据依赖,就可以将整体的耗时提升到300毫秒。但是虽然异步的方式可以提升整体的速度。但是在业务中嵌套太多非阻塞API其实对于整体的来说,BUG、开发效率也不高。并且业务中需要嵌套很多多线程、异步的代码。
其实可以看出,本质上为了提升性能,从多进程、多线程、到异步、协程都是通过不同的技术去解决问题。