操作系统、进程、线程、协程的一些事

操作系统、进程、线程、协程的一些事

闲来无事,写一篇文章,算是科普向吧。这篇文章也没有太高深的知识,因为太高深的,其实我也不懂。本文所有理论都是基本的理论,实际上有些理论可能在今天已经有所更新和优化,不完全一样了。

操作系统

操作系统是多久诞生的?从定义上来说,最早的时候,人们是通过各种按钮控制计算机的,这时候还没有操作系统。不过,如果我们认为,只要是让人们操作计算机的工具,都可以称为操作系统的话,说计算机从诞生之日起便有操作系统,这种话其实基本上也是正确的。操作系统的功能、原理是一直在发展的。几十年前的操作系统,和几十年后的,虽然它们都被称为操作系统,但是可能已经是两个东西了。

最早的计算机编程是通过纸带进行的。“程序员”将记载着程序的纸带放进计算机,计算机执行完毕后把结果纸带“吐”出来,这便是最早的人机交互。最早的时候计算机是独占的,你需要使用的话,就必须等前一个人用完。为了提高资源使用率,陆陆续续出现了批处理操作系统、分时操作系统和实时操作系统。分时操作系统在一定程度上已经接近于今天的操作系统了。它使用时间片的方式轮流分配CPU资源,用来处理多个用户的作业。

今天的操作系统,最基础的其实也是时间片,但是在它的基础上进行了很多改进。

进程

计算机逐渐发展,操作系统逐渐变得复杂。操作系统有一大功能叫“资源管理”,它怎么给程序分配资源呢?于是,进程的概念也被提出来了。进程是进行资源分配和调度的基本单位。我们仅从最简单的理论上来说,进程包括两个部分,一个是Process Control Block(PCB,程序控制块)和程序块。PCB是系统用来控制一个进程的“工具”,也是进程存在的标志。PCB中包含了进程的一些基本信息,例如,进程的ID,程序运行的堆栈,程序的运行数据等。而程序块则是进程真正执行的代码。

操作系统一般通过时间片算法来管理一个进程。它把CPU分成很多个很小的“片段”,并把它们轮流分配给进程使用。当时间片耗尽后,进程就会被系统“暂停”,将它的数据放入PCB,然后进程就进入了“就绪”队列。因此,从微观角度来讲,单个CPU某一时刻只能执行一个进程。

当进程需要其他外部资源时会怎样呢?例如一个进程需要读写文件,如果缓冲区没有它需要的数据,那么就意味着内核需要去外部储存读取。外部储存的读写对计算机来说是很慢的,CPU资源不应该被白白浪费,于是,内核就会让进程进入阻塞状态。当进程需要的资源准备好的时候,进程就可以继续了吗?答案是否定的,它需要重新“排队”,才能继续运行。

线程

线程的提出,是因为人们觉得进程切换开销太大,因此想要一个更“轻”的进程。线程提出之初,其实用处并不大,直到出现了多核心处理器。

前面提到了,进程是进行资源分配和调度的基本单位,那么,我们现在可以说,线程是进行资源调度的最小单位。与进程类似,线程也有一个叫做“Thread Control Block”的存在(TCB,线程控制块)。不过,这里需要注意,因为线程拥有的系统数据非常少,因此我们基本上忽略不计。所以说,线程并不能拥有资源,它只能使用进程的资源。线程比起进程更加轻量、灵活。进程的切换涉及到系统资源,而线程则不然。

因为以上特点,我们可以看出:

  • 进程自身的资源不可以被外部进程直接访问。各个进程之间是相互独立的。
  • 一个进程中的多个线程可以共享一部分资源。因此,在多线程环境下,有的操作需要加互斥锁。

在开始介绍线程的时候,我们说到了“多核处理器”。那么,为什么多核处理器能够让线程的能力得以体现?我们先说一下基本概念:处理器(CPU)=运算器+寄存器+控制器。其中,运算器就是我们所说的“核”。多个核心共享寄存器。和进程、线程类比一下是不是很相似?进程独占资源,线程则共享资源。因此,每有一个核心,就可以有一个线程在它上面运行。

Intel前几年有一项新技术叫做“超线程”,它大概意思就是,把一个核心“伪装”成多个核心,达到同时运行多个线程的目的。众所周知,线程在运行的时候总还是会有一些浪费资源的情况,因此,“超线程”对于多线程任务会有比较明显的提升,但是对于单线程任务可能反而会有一些下降。

简单的线程、进程调度

线程和进程在什么时候会进行调度?一般情况下在时间片耗尽的时候,它们就会进入“就绪”队列进行等待。不过,有些行为是不能被打断的。例如,创建进程的操作。因此,操作系统提出了一个叫“原语”的东西。如果一个操作是原语操作,那么,再它进行完之前,是不会因时间片耗尽而被系统“暂停”的。

除了时间片机制外,还有一个重要特性便是信号量机制。它一般可以这样描述:(注意这里只是描述,为了方便理解是怎么一回事)

typedef struct {
	int num;
	struct Queue q; //这里是一个队列
} S;
//wait实际上是原语
void wait(S *s) {
	s->num--;
	if (s->num < 0) { //如果已经没有可用资源了
		block(s->q); //阻塞当前进程和队列
	}
}
//signal实际上也是原语
void signal(S *s) {
	s->num++;
	if (s->num <= 0) { //队列里还有在等待的
		wakeup(s->q); //唤醒队列
	}
}

应用程序中则这样进行操作:

wait(s);
// Do something here
signal(s);

在某些资源是有限的,并且需要被应用程序独占的时候,很多情况下就会使用到信号量。

常见的异步和协程

其实到这一块,已经不算是太“底层”了。它们与CPU的分配关系已经变得更小了。与它们关系更多的还是操作系统。这里我们就只考虑Linux系统了。

对于多个IO事件系统会怎么处理呢?在之前一直是通过select/poll进行的。它们通过一个链表或数组(后面简写作数组),维护着多个句柄。应用每次都需要遍历整个数组,来得知哪些句柄发生了改变。我们使用C语言来进行描述:

while (1) {
	//select会阻塞当前进程,并在触发IO事件时唤醒
	select(&streams);
	for (int i = 0; i < get_length(streams); i++) {
		if (has_data(streams[i])) {
			read_until_unavailable(streams.data[i]);
		}
	}
}

这些过程中,除了会有进程间上下文切换的时间消耗外,还有从内核/用户空间大量的无脑内存拷贝、数组轮询的开销。

很多系统都有这么一个特点:连接的数量很多,但活动的连接其实只占了少部分。在这种情况下,大部分遍历都是在做无用功。因此,新的“epoll”模型出现了。epoll模型与select/poll区别有点大,简单的说,epoll使用了“事件”机制,使得每次不需要遍历所有句柄:

while (1) {
	//epoll_wait会阻塞当前进程,并在触发相应的IO事件时唤醒
	active_stream = epoll_wait(epollfd)
	for (int i = 0; i < get_length(active_stream); i++) {
		read_until_unavailable(active_stream[i]);
	}
}

异步与epoll十分类似,也是基于事件回调进行的。

协程被称为“轻量级的线程”,它的切换由应用控制,和异步非常相似,也是在特点情况下保存上下文,进入“阻塞”状态,在特定情况下被唤醒,恢复上下文并继续执行。

协程在开销上是稍大于异步的,具体原因,我推测是协程的上下文比异步更加复杂,在保存和恢复上耗时更大一些。不过,因为协程在代码编写方面带来的便利性和可维护性更加巨大,因此,协程的这些缺点基本上也被忽略不计。

说说“Meltdown”和“Spectre”

说了那么多,顺便也说说前段时间挺火的“Meltdown”和“Spectre”缺陷。

乱序执行

在处理器发展之初,基本上都是有序执行,即一条指令的执行必须在上一条指令之后进行。这就造成了一些浪费,因为有的时候,代码的执行顺序并不会影响结果,例如:

int a = 0, b = 1;
a++;
b++;

到底是a++先执行还是b++先执行,实际上结果都是一样的。因此,乱序执行技术出现了,CPU会将指令重排指令的执行顺序,达到加速执行的效果。

特权级

操作系统对于资源访问和分配应该是有优先级,而不是“众生平等”的。因此,在硬件设计方面,也对此有相应的优化,例如,Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上基本没有限制。

漏洞的产生

如果一个进程试图访问不能访问的内存,会怎么样呢?通常情况下,CPU会拒绝执行并抛出异常。不过,这种安全检查指令真正提交并对系统可见时才会进行。

在乱序执行的时候,因为有时间差的存在,某些语句需要访问内核态的内存,CPU就会将其读入缓存中,但并不会马上进行安全检查(因为程序其实还没有执行到这一步)。虽然到这一步之后,CPU会发现它们的访问并不合法,就会将其丢弃。但在这之前,前面的代码可以通过遍历并推测内存页的访问时间,获取到提前读入的数据。

小结

这些东西虽然我现在不太用的上,不过还是进行一些记录。毕竟,要想程序跑的快,原理不能丢啊