操作系统是如何一步步发明进程、线程的?

你是一名1960年代IBM计算机中心的工程师,你每天都在面对一个棘手的问题:如何让更多用户能够使用昂贵的System/360大型机。

这台价值数百万美元(相当于今天的数千万美元)的庞然大物,是当时最先进的计算设备,但它的价格实在太昂贵了,即使是大型企业也很难独自承担。

而且这台机器的用户需要提前好几天来登记使用时间,每次都只能为单个用户服务,所有任务都在串行执行。

当某个程序等待磁带读取时整个机器就会处于空闲状态,你体验到的是时间和金钱的双重流逝。

显然你会想为什么当某个程序读写外部慢速设备时让宝贵的CPU空闲呢?这就好比程序员在等待程序编译完成时ta还可以去写第二个需求的代码啊,必须并行起来。

必须并行起来

要实现这一点程序必须具备暂停运行以及恢复运行的能力,要想让程序具备暂停运行/恢复运行的能力就必须保存CPU上下文信息。

为此你必须定义一个结构体来保存处理器上下文信息:

复制
struct context { uint32_t eax, ebx, ecx, edx; // 通用寄存器 uint32_t esi, edi; // 源/目标变址寄存器 uint32_t esp, ebp; // 栈指针和基址指针 uint32_t eip; // 指令指针 uint32_t eflags; // 标志寄存器 uint16_t cs, ds, es, fs, gs, ss; // 段寄存器 };1.2.3.4.5.6.7.8.

每个运行起来的任务都需要这样一个结构体,当任务需要暂停时就把处理器上下文保存在context结构体中,需要恢复任务运行时就根据context中数据恢复处理器状态。

现在你就可以同时运行多个任务了,当任务A读取慢速磁带时就暂停任务A的运行并把CPU分配给任务B,这样你可以充分利用宝贵的机器资源。

多个程序相互干扰

当系统中可以运行多个任务后你发现了新的问题,那就是多个程序之间会相互干扰,因为在早期计算机系统中,程序被链接到固定的内存基址,且加载器缺乏重定位能力。当多个程序加载到内存时,程序中的变量可能被分配到相同的物理地址,导致互相覆盖。

举个例子:

复制
// program1.c int global_data = 100; // 全局变量 int main() { while(1) { global_data++; // 不断增加全局变量的值 ... } return0; } // program2.c int global_data = 100; // 同名全局变量 int main() { while(1) { global_data--; // 不断减少全局变量的值 ... } return0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.

这个示例中两个同时运行的程序global_data变量的内存地址可能相同,因此两个程序的运行会相互干扰,原因你很清楚,因为它们共享同一个内存空间,你开始意识到,仅仅依靠程序员的自觉来避免互相干扰是不够的,需要从系统层面提供隔离机制

于是,你开始设计一个新的抽象概念,让各个运行的程序彼此隔离,为每个程序提供独立的内存空间,你决定采用段氏内存管理,每个运行的程序中的各个段都有自己的内存区域:

复制
struct memory_map { uint32_t code_segment; // 代码段起始地址 uint32_t code_size; // 代码段大小 uint32_t data_segment; // 数据段起始地址 uint32_t data_size; // 数据段大小 uint32_t stack_segment; // 栈段起始地址 uint32_t stack_size; // 栈段大小 };1.2.3.4.5.6.7.8.9.10.
进程诞生了

现在你设计了struct context以及struct memory_map,显然它们都属于某一个运行起来的程序,“运行起来的程序”是一个新的概念,你给起了个名字叫做进程,process,现在进程上下文以及内存映射都可以放到进程这个结构体中:

复制
struct process { struct context ctx; struct memory_map mem; };1.2.3.4.

就这样你实现了操作系统最核心的功能:多任务。

进程这种设计效果嗷嗷好:

用户程序再也不会意外修改其他程序的数据可以同时运行多个程序,在它们之间来回切换即使一个程序崩溃,也不会影响其他程序的运行

不过,新的挑战也随之而来...

进程切换的性能瓶颈

多任务系统的使用解决了多用户共享计算机的问题。但很快,你就发现了一个令人头疼的新问题:随着系统中运行的进程越来越多,整个系统的响应速度开始明显下降。

通过仔细观察和测试,你发现问题出在进程切换上。每次从一个进程切换到另一个进程时,系统都需要执行大量的工作。

看一下你实现的进程:

复制
struct process { struct context ctx; struct memory_map mem; };1.2.3.4.

进程切换时处理器上下文和内存映射都需要切换,尤其对于现代操作系统中的页式内存管理来说内存映射切换的开销非常高(CR3切换、TLB刷新等)。

是否有必要创建过多进程?

真的有必要创建这么多进程吗?你仔细检查了一个开启大量进程的web服务器,web服务器会创建多个工作进程来处理不同的HTTP请求,这些工作进程运行完全相同的代码来处理请求,却各自占用一份独立的内存空间,同时这些进程在切换时又会带来大量开销。

但是等等,既然这些进程使用的是相同的代码,为什么不能让它们共享这部分内存呢?你开始意识到,也许可以创造一种新的执行单元,它们能共享进程的大部分资源,同时又保持足够的独立性,如果多个执行流可以共享同一个进程的资源,那切换的开销不就能大大降低了吗?

这个想法最终引导你走向了一个全新的概念。

线程概念的诞生

经过反复设计,你找到了一个突破性的解决方案:同一个进程内部支持多个执行流。这个想法来源于一个关键观察 ,很多时候,我们其实并不需要完全独立的进程,只需要能够并行执行不同的任务就够了。

于是,你设计了一个全新的概念 —— 线程。每个线程都是进程内的一个独立执行单元,它们:

共享进程的地址空间,这意味着所有线程可以直接访问相同的内存区域共享打开的文件描述符,避免了重复打开关闭文件的开销共享其他系统资源,如信号处理函数、进程工作目录等仅维护独立的执行栈和寄存器状态,确保每个线程可以独立执行

这就是线程的诞生故事,它完美平衡了资源共享和执行独立性,是操作系统发展史上一个重要的里程碑。

THE END
本站服务器由亿华云赞助提供-企业级高防云服务器