前言

虽然在程序员的职业生涯中,计算机底层知识可能很少直接涉及,但并不意味着这部分知识不重要。

对于计算机底层实现的深入了解,能帮助你了解计算机的运行原理,更好地设计高效的架构,并且有助于调试、判断错误。特别地,对于多线程的理解尤为重要:现今的程序架构都需要并发处理,如何协调不同线程之间的分工协作,避免死锁、同步出错等问题,是程序员应当具备的技能。对于后端工程师而言,良好的操作系统基础知识更是深刻理解并实现复杂分布式系统的前提条件。

进程 vs.线程

进程( process)与线程( thread)最大的区别是:进程拥有自己的地址空间,某进程内的线程对于其他进程不可见,即进程 A
不能通过传地址的方式直接读写进程 B 的存储区域。进程之间的通信需要通过进程间通信( Inter-Process Communication,
IPC)。与之相对的,同一进程的各线程间之间可以直接通过传递地址或全局变量的方式传递信息。

此外,进程作为操作系统中拥有资源和独立调度的基本单位,可以拥有多个线程。通常操作系统中运行的一个程序就对应一个进程。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。相比进程切换,线程切换的开销要小很多。线程于进程相互结合能够提高系统的运行效率。

线程可以分为两类:

一类是用户级线程( user level
thread)。对于这类线程,有关线程管理的所有工作都由应用程序完成,内核意识不到线程的存在。在应用程序启动后,操作系统分配给该程序一个进程号,以及其对应的内存空间等资源。应用程序通常先在一个线程中运行,该线程称为主线程。在其运行的某个时刻,可以通过调用线程库中的函数创建一个在相同进程中运行的新线程。
用户级线程的好处是非常高效,不需要进入内核空间,但并发效率不高。

另一类是内核级线程( kernel level
thread)。对于这类线程,有关线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只能调用内核线程的接口。内核维护进程及其内部的每个线程,调度也由内核基于线程架构完成。内核级线程的好处是,内核可以将不同线程更好地分配到不同的
CPU,以实现真正的并行计算。

事实上,在现代操作系统中,往往使用组合方式实现多线程,即线程创建完全在用户空间中完成,并且一个应用程序中的多个用户级线程被映射到一些内核级线程上,相当于是一种折中方案。

上下文切换

对于单核单线程 CPU 而言,在某一时刻只能执行一条 CPU 指令。上下文切换( Context Switch)是一种将 CPU
资源从一个进程分配给另一个进程的机制。从用户角度看,计算机能够并行运行多个进程,这恰恰是操作系统通过快速上下文切换造成的结果。在切换的过程中,操作系统需要先存储当前进程的状态(包括内存空间的指针,当前执行完的指令等等),再读入下一个进程的状态,然后执行此进程。

系统调用

系统调用( System Call)是程序向系统内核请求服务的方式。可以包括硬件相关的服务(例如,访问硬盘等),或者创建新进程,
调度其他进程等。系统调用是程序和操作系统之间的重要接口。

 

Semaphore/Mutex

当用户创立多个线程/进程时,如果不同线程/进程同时读写相同的内容,则可能造成读写错误,或者数据不一致。此时,需要通过加
锁的方式,控制核心区域( critical section)的访问权限。对于semaphore 而言,在初始化变量的时候可以控制允许多少个线程/进
程同时访问一个核心区域,其他的线程/进程会被堵塞,直到有人解锁。 Mutex 相当于只允许一个线程/进程访问的
semaphore。此外,根据实际需要,人们还实现了一种读写锁( read-write lock),它允许同时存在多个读取者(
reader),但任何时候至多只有一个写入者( writer),且不能与读取者共存。

 

死锁

在引入锁的同时,我们遇到了一个新的问题:死锁( Deadlock)。

死锁是指两个或多个线程/进程之间相互阻塞,以至于任何一个都不能继续运行,因此也不能解锁其他线程/进程。例如,线程 A 占有 lockA,并且尝试获取
lock B;而线程 2 占有 lock B,尝试获取 lock A。此时,两者相互阻塞,都无法继续运行。产生死锁的 4 个条件概括如下(只有当 4
个条件同时满足时才会产生死锁):

* 互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
* 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
* 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
* 循环等待条件: 若干进程间形成首尾相接循环等待资源的关系
 

生产者消费者

生产者消费者模型是一种常见的通信模型:生产者和消费者共享一个数据管道,生产者将数据写入 buffer,消费者从另一头读取数
据。对于数据管道,需要考虑为空和溢出的情况。同时,通常还需要将这部分共享内存用 mutex 加锁。在只有一个生产者一个消费者的情况下,可以设计无锁队列(
lockless queue),线程安全地直接读写数据。

 

进程间通信
 

在介绍进程的时候,我们提起过一个进程不能直接读写另一个进程的数据,两者之间的通信需要通过进程间通信进行。进程通信的方
式通常遵从生产者消费者模型,需要实现数据交换和同步两大功能。
( 1)共享内存( Shared-memory) + semaphore
       不同进程通过读写操作系统中特殊的共享内存进行数据交换,进程之间用 semaphore 实现同步。
( 2)信息传递( Message passing)
      进程在操作系统内部注册一个端口,并且监测有没有数据,其他进程直接写数据到该端口。该通信方式更加接近于网络通信方式。事实上, 网络通信也是一种
IPC,只是进程分布在不同机器上而已。

逻辑地址/物理地址/虚拟内存
 

所谓的逻辑地址,是指计算机用户(例如程序开发者)看到的地址。例如,当创建一个长度为 100 的整型数组时,操作系统返回一个
逻辑上的连续空间:指针指向数组第一个元素的内存地址。由于整型元素的大小为 4 个字节,故第二个元素的地址时起始地址加
4,以此类推。事实上,逻辑地址并不一定是元素存储的真实地址,即数组元素的物理地址(在内存条中所处的位置),物理地址并不是连续的,只不过操作系统通过地址映射,将逻辑地址映射成连续的,这样更符合人们的直观思维。

另一个重要概念是虚拟内存。操作系统读写内存的速度可以比读写磁盘的速度快几个量级。但是,内存价格也相对较高,不能大规模

扩展。于是,操作系统可以将部分不太常用的数据移出内存,存放到价格相对较低的磁盘缓存,以实现内存扩展。操作系统还可以通过算法预测哪部分存储到磁盘缓存的数据需要进行读写,提前把这部分数据读回内存。虚拟内存空间相对磁盘而言要小很多,因此,即使搜索虚拟内存空间也比直接搜索磁盘要快。唯一慢于磁盘的可能是,内存、虚拟内存中都没有所需要的数据,最终还需要从硬盘中直接读取。这就是为什么内存和虚拟内存中需要存储会被重复读写的数据,否则就
失去了缓存的意义。

现 代 计 算 机 中 有 一 个 专 门 的 转 译 缓 冲 区 ( Translation Lookaside Buffer,
TLB),用来实现虚拟地址到物理地址的快速转换。与内存/虚拟内存相关的还有以下两个概念:

      ( 1) Resident Set
     
 当一个进程在运行的时候,操作系统不会一次性加载进程的所有数据到内存,只会加载一部分正在用,以及预期要用的数据。其他数据可能存储在虚拟内存,交换区和硬盘文件系统上。被加载到内存的部分就是
resident set。

      ( 2) Thrashing
       由于 resident set 包含预期要用的数据,理想情况下,进程运行过程中用到的数据都会逐步加载进 resident
set。但事实往往并非如此:每当需要的内存页面( page)不在 resident set
中时,操作系统必须从虚拟内存或硬盘中读数据,这个过程被称为内存页面错误( page faults)。当操作系统需要花费大量时间去处理页面错误的情况就是
thrashing。
 

文件系统

UNIX 风格的文件系统利用树形结构管理文件。每个节点有多个指针,指向下一层节点或者文件的磁盘存储位置。文件节点还附有文件的操作信息(
metadata),包括修改时间、访问权限等。用户的访问权限通过访问控制表( Access Control List)和能力表( Capability
List)实现。前者从文件角度出发,标注了每个用户可以对该文件进行何种操作。后者从用户角度出发,标注了某用
户可以以什么权限操作哪些文件。

UNIX 的文件权限分为读、写和执行,用户组分为文件拥有者、组和所有用户。可以通过命令对三组用户分别设置权限。

 

实时 vs.分时操作系统

操作系统可以分为实时操作系统( Real-Time System),和分时操作系统( Sharing Time
System)。通常计算机采用的是分时,即多个进程/用户之间共享
CPU,从形势上实现多任务。各个用户/进程之间的调度并非精准度特别高,如果一个进程被锁住,可以给它分配更多的时间。而实时操作系统则不同,软件和硬件必须遵从严格的deadline,超过时限的进程可能直接被终止。在这样的操作系统中,每次加锁都需要仔细考虑。

 

编译器

对于高级语言来说,代码需要通过编译才能够运行。编译通过编译器( Compiler)实现,是一个将程序源代码转换成二进制机器码的
过程。计算机可以直接执行二进制代码。在编译的过程中,编译器需要进行词法分析( Lexical Analysis)、解析( Parsing)和过渡代码生成(
Intermediate Code Generation)。编译器的好坏可以直接影响最终代码的执行效率。
 

技术
下载桌面版
GitHub
Gitee
SourceForge
百度网盘(提取码:draw)
云服务器优惠
华为云优惠券
腾讯云优惠券
阿里云优惠券
Vultr优惠券
站点信息
问题反馈
邮箱:[email protected]
吐槽一下
QQ群:766591547
关注微信