Linux操作系统基础概念

419 阅读26分钟

基础概念

操作系统职责?

一说起操作系统,我们思索一下操作系统都做了哪些工作:进程管理,内存管理,文件管理,设备管理,网络管理,提供系统调用接口

什么是系统调用?系统调用的过程发生了什么?

应用程序通过调用系统调用接口,从用户态切换到内核态,请求操作系统内核完成一些特权操作。 系统调用过程,首先我们通过调用c库的外壳函数,外壳函数把参数和系统调用编号复制到寄存器,然后执行一条中断指令,引发处理器从 用户态陷入到内核态,并执行对应的中断处理程序。中断处理程序会保存寄存器值到内核栈中,校验系统调用编号,然后查找对应的系统 调用程序,完成执行后,从内核栈恢复寄存器值,系统调用返回值置于栈中,返回到外壳函数,切换为用户态。

什么是用户态和内核态?

cpu的指令分为一般指令和特权指令,用户态和内核态的区分本质就是权限的区分,只有cpu内核态时才能执行特权指令操作。 操作系统会把虚拟地址空间划分为用户空间和内核空间,内核空间运行内核程序和数据,内核态可以访问全部内核空间和用户空间, 而用户态只能访问用户空间,如果试图访问内核空间将引发硬件异常。用户态切换到内核态的场景主要有:系统中断,异常,系统调用。

什么是虚拟地址空间?

虚拟地址空间实际就是寻址的范围,取决于地址总线的寻址能力。如32位机器,则虚拟地址空间大小为4G左右

虚拟地址空间跟虚拟内存有什么关系?

虚拟内存是通过虚拟地址映射真实物理内存地址实现。首先物理内存会划分成小型的固定大小的页帧,而虚拟地址也按同样尺寸划分成页帧。 内核会维护每个进程的虚拟内存页表,通过内存管理单元可以转换为物理内存地址。映射的页并不都需要驻留在物理内存,驻留的部分为驻留集, 不驻留的页拷贝则存储在磁盘的交换区中,如果进程访问的页不在物理内存则会引发页面错误,内核挂起进程,将对应的页从交换区中载入。

虚拟内存的意义是什么?

通过虚拟内存的设计,每个进程可以相互隔离,运行时仿佛拥有整个世界,且只需要一部分的物理内存,这使物理内存可以容纳更多的进程运行,效率大大提高。 内核会进程维护虚拟内存的页表,则通过页表项的属性,可以实现不同内存的访问权限控制。同时通过页表项指向相同的物理内存,可以使不同进程实现共享内存。

进程与内存

进程到底是什么?

从内核的角度,进程是内核定义的一个抽象实体,并为该实体分配用以执行程序的系统资源。进程由用户空间内存和一系列维护进程状态信息的内核数据结构组成。

进程的虚拟地址空间布局是怎样?

虚拟地址从低到高,文本段,数据段(显性初始化的全局变量和静态变量),BSS段(0初始化的全局变量和静态变量),向上增长的堆, 堆边界与栈边界的未使用区域,向下增长的栈,命令行实参和环境列表,内核空间。

进程级页表是怎么维护的?

内核维护进程的页表是动态变化的即进程的有效虚拟地址范围是变化的,由内核分配和释放。如果试图访问无效的虚拟地址,则内核发送SIGSEGV信号。 通过增长堆的边界,进程第一次试图访问时,内核会自动分配新的物理内存页。

创建子进程会发生什么?

创建子进程相关的系统调用主要是fork和exec函数族。 fork系统调用创建子进程时,会拷贝父进程的页表。对于文本段,内核将每一个进程的文本段设置为只读,所以父子进程可以通过页表共享。 对于数据段,堆,栈各页,内核会把这些页设置为只读,当父子进程有试图修改页时,内核会捕获并为这些页创建物理拷贝,系统将新的物理页分配给捕获的进程,这就是写时复制技术。 exec系统调用,程序被完全替换,进程的资源(内存,寄存器等)完全被替换,保留了在内核维护的进程属性如进程id,进程级文件描述符表,当前的工作目录,环境变量等。

硬盘与文件系统

前面大概介绍了一下进程与内存的关系,现在我们关注另一个重要的操作系统职责文件管理

传统的机械硬盘的工作原理?和固态硬盘SSD有什么区别?

对于一个机械硬盘,其格式化存储容量 = 磁头数(不同盘片) * 磁道(同一个盘片圆圈) * 扇区数(磁盘划分的扇区)。所以定位数据时根据地址,确定 磁道,然后旋转到对应的扇区进行数据的读写。一次读写为一次io操作,耗时为寻道时间+旋转延迟时间。相比与cpu和内存的读写时间,则耗费的时间更为明显。 固态硬盘是基于闪存的电子硬盘,其特点是读写快,质量轻,具体就不深入。我们后续的讨论都基于传统的机械硬盘。

文件系统在硬盘上是如何组织的?

首先硬盘的存取的最小单位是扇区,一般为512字节大小。而对于文件系统,一般会用逻辑块(若干各扇区)来读写,减少io,块大小取决于具体文件系统和设置。 一般的文件系统的组织大概是这样的:引导块+超级块+i_node表+数据逻辑块。引导块一般用来引导操作系统的信息。超级块会存放文件系统相关的信息,如i节点表 容量,逻辑块大小及数量。i节点表存放的i节点,每一个对应文件系统的一个文件或目录的信息,同时存储数据块的索引。i节点的索引是一个数组,存放15个块指针 前12块指针指向文件的前12个数据块,第13个指向一个间接索引表(根据块大小和指针大小可以计算间接索引表的指针数量)。如果文件更大,则第14个指向二级间接 索引表,第15个指向三级间接索引。

硬盘上只能有一个文件系统吗?

并不是,硬盘可以划分为多个分区,每个分区可以存放不同的文件系统。分区也可以作为裸设备直接存数据,或者作为交换区等。

文件系统中i节点对应一个文件,那目录,软链接,硬连接怎么实现?

i节点通过类型区分具体的文件类型,目录i节点指向的数据区域存放了目录下的文件名称和对应的i节点 软链接也是符号链接,数据区域存放的是指向文件的路径,由操作系统解引用,可以在不同文件系统建立链接 硬链接则会增加文件i节点的引用计数,只有当i节点的引用计数为0时,文件才会被删除。所以硬链接不能跨文件系统。

文件的读写过程是怎样的呢?

首先,内核会维护进程级的文件描述符表,每一个表项指向了系统级的打开文件表,每一个打开文件表保存了文件的状态标志和inode等信息。 进程从用户态调用read系统调用时,实际是完成从用户态缓冲区到内核高速缓冲区的复制,write则反过来。那么不同文件的读写在高速缓冲区又是如何管理的。 我们之前提过虚拟内存的概念,内核高速缓冲区也是这样的处理机制。每一个inode保存了文件的所有块号。一个inode对应一个address_space的结构, 而address_space对应一个页缓存基数树。通过文件offset和页缓存树,可以定位到具体的映射页,可以定位页偏移量,可定位到文件系统块号再对应扇区号。 于是解决了物理内存页与文件的映射。

读文件流程:发起read系统调用,内核通过进程描述符定位到虚拟文件系统的已打开文件表项,然后定位到inode, 在inode中,通过offset计算要读取的页,在inode找到文件对应的address_space,访问文件页缓存树,如果命中,则返回文件内容,如果页缓存缺失,则产生缺页异常。 创建一个页缓存页,通过inode找到文件该页的磁盘地址,读取相应的页填充缓存。再执行查找页缓存。

写文件流程:如果页缓存命中,则修改页缓存,并标记为脏页。如果页缓存缺失,产生缺页异常,创建页缓存页,读取磁盘对应的页填充,再执行前面的步骤。 脏页刷回磁盘可以手动调用sync或fsync,也可以pdflush进程定时把脏页刷回磁盘

因为读写并不是实时落盘,那么数据是否会丢失?有什么机制保障呢?

文件读写时,通过手动sync或pdflush自动刷盘,如果写一半断电了那么可能会出现一个扇区只写了一部分数据的情况。而且因为现在大多磁盘本身自带缓存, 所以刷盘实际也只是刷到磁盘缓存,所以文件系统一半没法保证数据一定不丢失。但是文件系统提供了一些策略在设计上保证文件系统结构上可恢复,但不保证 用户数据可恢复。其中一个常见重要方案就是日志技术。data journaling方式,先把需要执行的操作写入到日志,再进行真正的元数据和用户数据写入。 如果崩溃重启,则重新执行日志操作。meta journaling则先写数据,再写元数据的日志,日志有效则数据有效。

信号模型

信号出现的场景?

信号可以由内核发送给进程,也可以进程发给进程。标准信号的范围1--31,标准信号一般会有默认处理。常见信号出现场景:硬件异常被0除,访问无效内存区域, 终端中断,软件事件如定时器到期,子进程退出等。针对信号,进程可以设置信号处理器函数,捕获信号,执行对应的处理。信号又可分为非实时信号和实时信号, 非实时信号发给进程时,如果已经注册过,则不会再注册,即在被提交之前信号不会排队。而实时信号则可以重复注册,有排队机制。

信号处理器函数怎么设计?

首先需要考虑的一点是,线程安全。多线程程序运行时,内核会随机选择一个线程执行信号处理器函数。而线程安全,即保证函数由多个线程交叉执行时, 其效果也与各线程以未定义顺序执行时一致。除了保证线程安全,也可以通过另外一种方式,执行不安全函数或可能改变全局数据时,通过信号掩码阻塞信号的传递 执行完后再解阻塞。信号处理器函数可以捕获大部分的信号,但SIGKILL,SIGSTOP属于强杀信号,不能捕获处理。那另外一个问题,想想我们要是无限递归导致栈溢出, 这时内核发送的信号,我们信号处理器函数捕获,但已经没有栈空间,如何进行处理?我们可以申请一块单独的空间,交给信号处理器函数,作为备选栈空间。

什么时候会执行信号处理器函数?

给一个进程发送信号,内核会标记对应的信号位。当进程被调度执行时,或者从内核态切换回用户态时。信号处理器函数就可以捕获信号进行处理。如果进程正在进行 阻塞式的系统调用,则信号传递可能引起调用过早完成,错误码设置位EINTR,可以在创建信号处理器函数时设置SA_RESTART标志,从而令内核代表进程自动重启 系统调用,但这只对部分系统调用有效。假如进程创建了多个信号处理器函数,那么收到多个信号时,又是如何处理?信号会按编号顺序传递,如果信号处理器函数 内有系统调用,或者有发生从内核态到用户态切换时,则也会转向执行另一个信号处理器函数。

进程管理与进程间通信

当我们打开终端,进入shell交互界面,我们输入命令,启动一个脚本,创建一个进程。这个时候,这个进程的父进程会是当前的shell进程。那么向上溯源, 我们通过pstree可以查看整个系统的进程树。而根节点则是1号进程init进程,而init进程的父进程是0号进程idle进程,运行在内核态。

什么是init进程?

当内核启动自己,内核程序装入内存,开始运行,初始化所有数据后,通过启动用户进程init完成引导进程的内核部分。包括启动一些重要的守护进程,检查文件系统等。 同时,当进程的父进程退出,子进程会变为孤儿进程,就会由init进程管理,当子进程退出时就由init进程回收。为什么需要回收,退出不就完了。这就涉及另外一个概念, 僵尸进程。当子进程退出,而父进程又没有wait或waitpid,则子进程将会释放资源,但是在内核的进程数据结构还保留着,这是为了期待某一个时刻由父进程来检查子进程退出时的状态。 而系统能够支持的进程数又是有限的,如果出现太多僵尸进程,将会产生危害。解决方法,则是通过杀死其父进程,则该子进程就会交给init进程管理。在系统关闭的时候,init进程 会向所有的子进程发送SIGTERM信号,5秒后发送SIGKILL信号。

如何创建守护进程呢?

首先我们需要了解控制终端,会话,进程组的概念。

进程组顾名思义就是一组进程,有同样的进程组id。第一个进程为进程组首进程。当我们用管道方式输入一长串命令,最后结尾带上&,执行时,则这组进程 会成为后台进程组,而不带&则会成为前台进程组。会话是进程组的集合,一组进程组有同样的会话id。如同时有后台进程组和前台进程组,它们属于同一个会话。 一个控制终端通过前台进程组id+会话id确定。一个会话中,同一时刻只能有一个前台进程组,而前台组是唯一能都读写控制终端的进程组,当进程失去终端连接后, 内核会发送SIGHUP信号。进程组的首进程,不能创建一个新的会话。

了解了这些之后,我们来看完整的创建守护进程过程: 首先是fork一个子进程,父进程退出,子进程setsid创建一个新会话,释放与终端之间的所有关联关系。避免守护进程可能打开一个终端,再fork一次,父 进程退出,子进程不会成为会话组长,这时进程永远不会重新请求一个控制终端。清除进程的umask,确保守护进程创建文件和目录的权限。修改守护进程的当前 工作目录,关闭守护进程打开的所有描述符,打开/dev/null 复制占用0,1,2描述符号,避免守护进程使用这些描述符出现异常,也避免打开文件时使用这些描述符。 常见的守护进程如,supervisord,mysqld,可以去研究对应的源码,看具体的实现。

进程和线程有什么关系和区别呢?

前面我们有说到进程到底是什么东西。那线程又到底是什么,我们可以把进程的属性资源与执行分开。而执行部分我们看成一个单元对象,则我们为了提升效率, 增加一些执行单元。这些执行单元则是线程。所以我们可以把进程看成是,进程的属性资源加上一个主线程。进程是操作系统资源分配的单位,进程相互独立。 线程是执行单元,属于进程,共享同一进程的资源。进程相互独立,每次进行调度时,需要保存进程的上下文状态才能切换,开销就会比较大。而线程相比于进程 切换时,线程是执行单元,共享了进程的上下文,切换开销会小很多。进程间相互独立,所以协作通信需要进程间通信IPC工具实现。而线程协作通信则简单得多, 如共享全局变量。

线程可以共享进程什么东西?有哪些又是线程独有的?

上面说到线程是执行单元,共享进程资源。那到底共享了什么,我们可以用代码经验思考一下,一个进程对象大概会有什么属性。进程id,组id,父进程id, 会话id,有效用户id,有效组id,虚拟地址空间,文件描述符表,当前工作目录,文件权限掩码,资源限制,信号处理器。没错,这些东西,线程都是可以共享的。 线程独有的内容,线程id,线程栈,线程特有数据thread local,错误码变量,信号掩码,备选信号栈。

多线程共享同一虚拟地址空间,如何处理同时读写同一资源?

前面我们在谈信号处理器设计时提到了线程安全的概念。多线程编程时,我们要时刻想着线程可能交叉执行。这里就引入一个概念,临界区:访问同一共享资源的 代码片段,并且这段代码的执行应该是原子操作。那么怎样才能保证原子操作,一个简单又直接的答案,就是加互斥锁。线程同步还提供了另外一个工具,条件变量。 互斥锁防止多个线程同时访问同一共享变量,而条件变量允许一个线程就某个共享变量的状态变化通知其他线程。一般条件变量是与互斥锁一起使用,当一个线程加锁后, 等待条件变量状态变化时,阻塞同时释放锁,让其他线程可以获取锁。其他线程唤醒等待条件变量的线程,则该线程会重新尝试加锁。

有时,我们可能在主线程执行时,需启动多个线程执行一个函数不同任务。而主线程需要等待多个线程返回,那么可以通过pthread_join等待线程, 这有个前提是创建线程的时候,设置线程是joinable。如果一开始,设置线程是detached状态,则线程运行完之后系统会自动清理,不能使用pthread_join。 反之,joinable的线程,可以在需要的时候pthread_detach。

什么是死锁?如何解决?

当我们引入了锁,那就需要考虑死锁的问题。典型的是AB-BA的模式,即有A,B两把锁,不同线程的加锁顺序不一样,导致互相等待对方释放锁。一个简单的解决方法是 给加锁设置超时,如果超时则失败回滚。当然最根本的还是要从源头防止,统一约定加锁的层级顺序。而对于一些基础软件,如mysql,提供了死锁的检测机制,当发现死锁时 能够将持有最少行级x锁的事务回滚。

线程是执行单元,那内核调度的是进程还是线程?

内核调度是通过内核调度实体KSE实现的,如果多线程是内核级线程,则一个线程映射一个内核调度实体。如果是用户级线程,则是一个进程映射一个内核调度实体。 我们就把用户级线程实现的进程当成对应的一个内核级线程来看调度。

调度时,需要进行上文切换,这个动作主要是在内核中完成。将当前活动的线程状态保存, 包括寄存器,内核中的状态信息等。将下一个线程恢复寄存器值,内核状态。如果前后两个"线程"属于同一个进程,则不需要切换虚拟内存,栈等信息。如果不属于,则需要。 所以,线程的实现模型有几种,1:1,一个线程对应一个调度实体,线程由内核调度,多处理器可以并行,但切换开销大。m:1,用户级线程,切换由程序管理,如果一个线程 阻塞了,则其他线程也将无法执行。m:n,这种模式则需要实现调度管理,实现比较复杂,go语言的协程机制实现了该模型。

线程如何调度?

在Linux操作系统中,默认使用的是循环时间片的调度算法。每个线程排队获得时间片。线程一直持有CPU,直到以下几种情况:时间片耗尽,调用了阻塞式系统调用, sched_yield主动让出cpu,进程终止,被一个优先级更高的抢占。线程的优先级,则是通过nice值一个权重,影响调度算法。这个值是进程的一个属性, 值范围为-19--19,这个值越低优先级越高,普通进程默认为0,负数一般为特权级进程。在多处理器运行时,在条件允许的情况下可以将原线程重新调度到之前的cpu, 这就是CPU亲和力。切换的时候,如果在原来的cpu高速缓冲器存在线程的数据,则在其他cpu上运行时,需要先使该数据失效,而这一步也会耗时。

进程通信方式有哪些?

线程是在同一个虚拟世界,所以通信相对简单。当需要跨越不同世界时,就需要进程间通信。管道,FIFO,socket,信号,消息队列,共享内存,信号量, 文件锁。

管道:内核内存中维护的一个缓冲区,pipe系统调用返回读写两个描述符,可以通过继承也可以通过unix socket方式传递。如popen函数执行过程,调用进程创建管道, 创建子进程shell,shell再创建子进程执行命令,并把结果通过管道返回。

FIFO:与管道类似,不同的是它在文件系统中拥有名称,打开方式就像打开一个文件一样,所以又叫命名管道。

消息队列:我们只看posix的实现,进程之间以消息的形式交换数据,消息支持优先级。mq_open打开的消息队列会增加引用计数,返回的是消息队列描述符,这就类似于文件的方式。 消息队列支持mq_notify注册异步消息通知。

信号量:信号量是一个整数,值不能小于0,如果进程试图将信号量减小到小于0,则调用会阻塞或返回相应错误。sem_open会返回对应的handle,sem_post递增,sem_wait递减。 Linux把命名信号量创建成小型的posix共享内存对象,挂载在/dev/shm/tmpfs下。

文件锁:内核能将文件锁与打开的文件相关联,复制文件描述符时,会引用同一个文件锁。flock可以对整个文件加锁。fcntl可以对文件区域加锁。通过查看/proc/locks可以查看文件锁。 一个守护进程确保系统中只有一个进程实例的方式,可以通过创建一个文件,并在文件放置一把写锁,在运行期间一直持有在终止前删除。一般/var/run下会放置这类文件。

共享内存:posix共享内存能让无关的进程共享一个内存映射区域而无需创建一个相应的文件。Linux使用挂载/dev/shm/tmpfs的文件系统,这个文件系统具有内核持久性, 它包含的共享内存对象会一直持久直到系统关闭。shm_open打开一个指定名称的共享内存对象,返回描述符。随后通过mmap创建共享文件映射实现。

什么是内存映射?

前面我们说到文件读写过程是有说到内存映射文件区域的过程。通过mmap系统调用可以在进程的虚拟空间创建一个映射,映射的大小是分页的整数倍。 内存映射可以分为几类,私有文件映射,私有匿名映射,共享文件映射,共享匿名映射。

私有文件映射:创建一个映射,映射内容初始化为文件对应区域的内容。多个进程可以映射同一个文件区域,共享同样的物理内存分页。如进程加载的文本段, 共享库,则属于这类。

私有匿名映射:本质是通过私有文件映射,不过打开的是一个系统特殊文件/dev/zero。映射的区域会初始化位0,主要用来分配进程的私有内存。

共享文件映射:内容会初始化为文件区域的内容,对内容的修改变更会自动写入,对所有共享进程可见。内存映射io,减少read,write系统调用。

共享匿名映射:不会进行写时复制,相关进程可以共享相同的物理分页。fork会继承,exec会清除。

每个映射都可以有不同的保护规则,违法访问会引起SIGSEGV,系统调用msync可以阻塞等待内存区域所有变更写入到磁盘,也可以刷到内核高速缓冲区。 每个mmap系统调用会创建一个独立的虚拟内存区域VMA,对应/procc//maps的一行记录。

什么是共享库?

首先,什么是静态库。静态库是一组目标文件组织进单个文件,构建多个程序时无需重新编译,只需要链接即可。但这有两个主要缺点,一个是规模比较大的程序, 一般需要多个库,而每个库代码在程序中都会有一份拷贝,会使整个二进制文件变大。另一个是每次库更新都需要重新编译,重新链接。共享库应运而生,目标模块 由需要的程序共享,不会被复制到可执行文件中。程序文件更新,启动更快,同时共享库集中维护,所有使用的程序都能得到更新。

那共享库的工作原理是什么呢?我们制作一个共享库时,首先在编译的时候通过命令gcc -c -fPIC a.c b.c指定编译器生成位置独立的代码, 使代码在运行时可以被放置在任意一个虚拟地址处。然后通过命令gcc -shared -o lib.so a.o b.o生成共享库。链接阶段,将共享库的名称嵌入可执行文件中, 所以共享库需要放在动态链接搜索的标准目录中或者将目录添加到LD_LIBRARY_PATH。运行时解析如果库不在内存就将库加载进内存,后续其他使用共享库 就可以共享同样的物理内存。

默认情况下,当主程序和库定义了相同全局变量或函数时,主程序会覆盖库的定义。如果多个库定义,则会按扫描顺序以第一个为准。 通过-Bsynbolic参数,可以使库对全局符号的应用优先绑定在库中的定义。

小结

本文只是个人对操作系统知识点的一个回顾串联总结,还没有更深入地去探索其中的源码实现,所以可能有描述不准确的地方,如有疑问可以自己搜索相关知识深入。 在实际开发工作中,基础性的知识能够帮助我们看得更广,想得更深,更是能够帮助我们减少bug和快速定位bug。

参考资料:《Linux-UNIX系统编程手册》、《现代操作系统》