现代操作系统原理与实现

--- 2022.10.23完结 《现代操作系统原理与实现》随书博客 ---​​

yanyanran's Notes

一、操作系统结构

1、微内核和外核的主要区别是什么?他们的优势和劣势分别是什么?

  • 微内核操作系统

微内核将传统宏内核中的驱动程序甚至包括许多功能,比如文件系统、网络、GUI等都变成用户态的进程。而内核中仅仅保存一些最重要的功能:进程管理、内存管理进程间通信以及硬件抽象层。

  1. 优势

    因为做了板块分离,所以在功能维护和升级的效率就会更高;同时系统也会更加稳定、安全、可定制化;并且因为微内核把各种服务和驱动都分离开来,这就允许让各种系统服务运行在不同的芯片或者电脑上,可以让很多太计算机来运行同一个操作系统。这对分布式计算和云计算提供了更多可能。

  2. 劣势

    性能问题。相比于把所有功能都打包一起的宏内核,微内核的内核态和用户态之间的频繁切换会很耗时。

  • 外核

外核的目标是让程序获得更多控制硬件的自由,让开发者对硬件的控制更深。外核操作系统减少“操作系统必须提供构建程序的抽象内容”的传统概念。外核实现了应用级资源管理,即“由应用程序而不是操作系统管理硬件资源”。



二、内存管理

1、关于物理内存和虚拟内存?

在虚拟内存出现之前,程序寻址用的都是物理地址。但物理地址是有限的,所以有了虚拟内存的存在。

出现虚拟地址后的程序执行时,CPU会把虚拟地址转化为实际的物理地址,然后通过物理地址访问物理内存

  • 内存管理单元MMU:地址翻译

    由此引出内存管理单元的概念,它负责虚拟地址到物理地址之间的转换。

  • TLB:加速地址翻译的重要硬件

    为了减少地址翻译的访存次数,MMU引入了转址旁路缓存--TLB。

    TLB缓存了虚拟页号和物理页号之间的映射关系,可以类拟为一个kv哈希表

    虚拟内存里有一个很重要的结构:页表

    页表是内存管理系统中的数据结构,用于向每个进程提供一致的虚拟地址空间,每个页表项保存的是虚拟地址到物理地址的映射以及一些管理标志

    应用进程只能访问虚拟地址,内核必须借助页表和硬件把虚拟地址翻译为对物理地址的访问

2、虚拟内存的功能

  • 内存共享

    内存共享允许同一个物理页中的不同程序共享一块内存。如下图所示,程序A和B的虚拟页都被映射到了同一个块内存上,这意味着程序A和B读取对应的内存页所显示的结果是一样的,同时也代表着相互之间可以看到对方修改的内容。

  • 写时拷贝

    copy on write,即隐式共享。原理是将复制操作推迟到第一次写入操作时进行,创建一个新副本时不是复制资源,而是共享原始副本(复制页表);只有修改的时候才会执行复制操作。

    比如当我们使用fork创建子进程的时候,操作系统需要将父进程虚拟内存空间里的大部分内容全部复制到子进程中 --> 耗时且浪费大量物理内存。

    这时候使用写时拷贝技术后,内核不会复制进程的整个地址空间而只是复制页表,fork之后的父子进程的地址空间指向同样的;物理内存页。

    写时拷贝能节约物理内存资源,还可以让父子进程以只读的方式共享全部内存数据,避免内存拷贝操作带来的时间和空间开销。

  • 内存去重

    KSM,由操作系统自发开始。操作系统定期检查扫描具有相同内容的物理页,找到映射物理页的虚拟页,只保留一个物理页,然后释放其他的物理页。

    不过KSM会对程序访存延时造成影响。这儿有一个安全隐患,就是会有不法分子通过穷举法,不断构造数据然后等待系统去重,再通过时延确认是否发生了去重从而猜数据。

  • 内存压缩

    就是当内存不够的时候,系统会选择部分不常用的内存页,压缩其中的数据从而达到腾出内存的效果。再访问的时候解压即可。

  • 大页

    前面说到过TLB --> 加速地址翻译的部件。因为翻译每个内存页都需要占用一个TLB缓存项,而CPU中的TLB又是有限的,所以当程序数量变大时就会出现TLB不够用的情况。这时候大页来了,使用大页有以下好处:

    1. 减少TLB缓存项的使用,从而提高TLB命中率;

    2. 减少页表级数,提升查询页表的效率。

过度使用大页也有缺点:程序没有完全使用完整个大页从而造成资源浪费;大页的使用也会增加操作系统的管理内存的复杂度。



三、进程与线程

1、进程

  • 进程控制块:PCB

    内核中每个进程都通过一个数据结构来保存它的状态,这个数据结构就叫PCB。

    PCB中包括进程标识符(PID)、进程状态、虚拟内存状态等等信息。

关于寄存器、CPU和内存的关系:CPU 寄存器 和内存三者之间的关系_春已暖花已开的博客-CSDN博客_寄存器和cpu的关系

  • 创建进程:fork() --- “调用一次,返回两次”

    fork不接受任何参数。

    1. 对于父进程,fork返回子进程的PID

    2. 对于子进程,fork返回0

    fork刚完成时父子进程的内存、寄存器都完全一样,但他俩是完全独立的两个进程。他俩PID和虚拟内存空间都是完全不同的,互不打扰。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
​
int main(int argc, char *argv) {
    int x = 42;
    int rc = fork();
    if(rc < 0) {
        fprintf(stderr, "Fork failed\n");
    } else if(rc == 0) {
        // 子进程
        printf("Child process rc is %d; The value of x is %d\n",rc, x);
    } else {
        // 父进程
        printf("Parent process rc is %d; The value of x is %d\n", rc, x);
    }
}
/*
Parent process rc is 75758; The value of x is 42
Child process rc is 0; The value of x is 42
*/

当我们对文件操作时:

int file() {
    str[10] = 0;
    int fd = open("test.txt", O_RDWR);  // 先open再fork
    if(fork() == 0) {
        ssize_t cnt = read(fd, str, 10);
        printf("Child process:%s\n", (char *)str);
    } else {
        ssize_t cnt = read(fd, str, 10);
        printf("Parent process:%s\n", (char *)str);
    }
    return 0;
}
/*
两个输出字符不一样
*/

会发现父进程和子进程的输出文件内容不一样!这是咋回事,前面不说了是“完全一样的拷贝”吗?问题的关键就在于“拷贝”上。

每个进程在运行过程中都会维护一张已打开的文件描述符表(fd表),它是操作系统提供的对文件引用的抽象。

fd会使用偏移量记录当前进程对文件读取的位置。

之所以使用文件描述符而非直接指向文件系统中的某个文件,是因为文件结构可能会因为文件系统的不同而发生改变,所以将其单一抽象出来有利于操作系统管理。

上面的例子是先open再fork的。在fork过程中,父子进程会获得一模一样的fd表。因此会指向相同的文件抽象,偏移量也是共用的。而Linux在实现read操作的时候会对文件抽象加锁,所以读到的字符串肯定不一样:

如果想要让父子进程独立开,读取到相同的字符串,只需要将模式改为先fork再open即可。

  • 3执行进程:exec接口

    创建父子进程后,我们通常希望子进程去执行其他的命令,这时候就会用到exec接口。此接口目前最全面的方法是execve:

    int execve(const char *patname, char *const argv[], char *const encp[]);
    // pathname:进程需载入的可执行文件的路径
    // argv:进程执行参数
    // envp:为进程定义的环境变量(键值对字符串形式传入)

    调用execve时,操作系统至少经历以下步骤:

    1. 根据pathname将可执行文件的数据段和代码段载入当前进程的地址空间中;

    2. 重新初始化堆和栈;

    3. 将PC寄存器设置到可执行文件代码段定义的入口点,该入口点最终会调用main

      PC寄存器:程序计数器

      用于存放下一条指令所在单元的地址

      当执行一条命令时,首先根据PC中存放的指令地址,将指令由内存取到指令寄存器中,也就是我们所说的“取指令”的过程。

  • 进程关系

    进程间的关系可以用的结构来描述,每个进程的task_struct都会记录自己的父进程和子进程。

    1. 进程组:进程的集合

    2. 会话:进程组的集合

  • 进程监控:wait接口

    和exec结构相似,wait操作主要使用waitpid来监控:

    int waitpid(pid_t pid, int *wstatus, int options);
    // pid:需要等待的子进程id
    // wstatus:保存子进程状态
    // options:包含一些选项

    具体使用如下:

    #include <stdio.h>
    #include <stdlib.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <unistd.h>
    ​
    int main(int argc,  char *argv[]) {
        int rc = fork();
        if(rc < 0) {
            // fork失败
            fprintf(stderr, "Fork faoled\n");
        }else if(rc == 0) {
            // 子进程
            printf("Child process: exiting.\n");
        } else {
            // 父进程
            int status = 0;
            if(waitpid(rc, &status, 0) < 0) {
                // waitpid失败
                fprintf(stderr, "Waitpid failed\n");
                exit(-1);
            }
            if(WIFEXITED(status)) { // 判断子进程是否退出
                printf("Parents process: my child has exited\n");
            }else {
                fprintf(stderr, "Parent process :waitpid returns for unkown reason\n");
            }
        }
    }
    /*
    Child process: exiting.
    Parents process: my child has exited
    */

    上面这个例子中,父进程调用waitpid对子进程进行监控。如果子进程退出,则witpid会立刻返回并设置status变量的值;如果子进程没有退出,那么waitpid会阻塞并等待子进程退出。同时父进程可以通过访问statu值来查看子进程状态。

僵尸进程

wait操作不仅起到监控作用,同时还会回收结束的子进程和释放资源。

如果父进程没有调用wait的话,结束的子进程的pid和终止信息将会一直保留,这将会占用内存。如果一个父进程一直fork子进程而不wait的话,子进程的pid将会不断占取大量的内存空间,最终使得内核资源不够而无法fork。

  • 创建和执行合二为一:posix_spawn

前面说到的fork存在一些缺点:比如说每当操作系统为进程结构添加功能时都要考虑对fork的修改;再者就是fork性能太差,拷贝时间遇到大内存时耗时过长.....

posix_spawn是fork+exec的结合:

int posix_spawn(pid_t *pid, 
                const char *path,
                const posix_spawn_file_actions_t *file_actions, 
                const posix_spawnattr_t *attrp, 
                char *const argv[], 
                char *const envp[]);
// pid:在spawn返回时被写入新进程的PID
// path:进程需载入的可执行文件的路径
// file_actions:()
// attrp:()
// argv:进程执行参数
// envp:为进程定义的环境变量(键值对字符串形式传入)

posix_spawn调用过程中,在exec过程执行前会根据file_actions和attrp两个参数的配置完成一系列操作。

相比于fork+exec的组合,spawn的性能要优先于组合;但由于参数的原因,灵活度没有组合高。

  • 特定条件下的fork改良:vfork()

    pid_t vfork(void);

    在进程创建后立即使用exec,由于exec本身会创建地址空间,因此vfork与fork相比省去了一次地 址空间的拷贝

  • 紧密控制:clone

    如果程序希望选择性共享父子进程的部分资源,这时候fork就不管用了。Linux根据rfork接口提出了类似的clone接口,支持程序通过参数对创建过程进行更多控制。


2、线程

早期计算机的最小运行程序单位是进程。但随着计算机硬件水平的提高,计算机拥有更多CPU核心,以线程为最小单位就显得有些笨重:

  1. 首先,创建线程的开销较大。即使是使用fork创建进程,也需要对父进程的状态进行大量拷贝;

  2. 进程间的虚拟内存空间是独立的,所以在进程间共享数据相对而言比较麻烦(共享虚拟内存页粒度太大、进程间通信开销太大)

所以有了线程中的一个个独立执行单元:线程。线程之间共享进程地址空间,但又各自保存运行时所需的状态。

多线程地址空间布局:内核栈与用户栈是分离的状态,除栈以外其余区域均为共享状态。

  • 用户态线程和内核态线程

    内核态线程由内核创建,受操作系统调度器直接调用;而用户态线程是应用自己创建的,内核不可见自然也就不受操作系统调度。

    为了实现内核态线程与用户态线程的协作,操作系统建立了两类线程之间的关系称之为多线程模型

  • 线程控制块TCB

    前面介绍过进程的控制块PCB。和PCB类似,内核态TCB中主要存储的是线程的运行状态、内存映射、标识符等等信息;而用户态TCB结构主要靠线程库决定。

    我们可以把用户态TCB看作是内核态TCB的扩展 -- 它可以用来存储更多与用户态相关的信息。其中有一个很重要的功能是TLS:线程本地存储

    线程本地存储:TLS

    我们知道一个进程中的全局变量和静态static变量是归所有线程共享的。在一个线程中修改这个变量将会对所有的线程都生效。

    这是个好事,但不完全是。好在于所有的线程之间改变变量会变得非常嗯块,缺点就是如果一个线程死掉了,其他线程页将会不保。

    这时我们可以使用TLS机制为每一个使用该全局变量的线程都提供一个变量值的副本,每一个线程都可以任意更改自己的副本而不会与其他线程副本冲突。

  • 线程接口

    创建线程pthread_create
    线程退出pthread_exit
    出让资源pthread_yield
    线程合并pthread_join
    挂起和唤醒pthread_cond_wait

    不过和进程不一样,线程退出exit的调用不是必要的。当一个线程的主函数执行结束的时候pthread_exit会被隐式调用。


3、纤程

纤程是一种轻量化的线程(用户态线程)。因为用户态线程更加轻量级,要比内核态线程的创建和切换的开销要小很多,可以增加系统的可扩展性。



四、操作系统调度(*)

1、单核调度策略

假设前提是当前系统只有一个可用的CPU核心。

(1)经典调度

  • 先到先得 -- FCFS

    经典调度最容易想到的就是先到先得(FCFS)策略。但它的弊端在于:

    1. 在长短任务混合的状态下对短任务不友好比如在一个任务队列中顺序为先执行一个长任务,那么跟在它后面的短就要等待长任务执行完,这会大大延长短任务的周转时间;

    2. FCFS任务对计算密集型任务友好,但对IO密集型任务不友好。因为IO密集型任务会花费大量时间用于等待IO而不是处理请求,并且等待过程中呈阻塞状态。

说了那么多,说白了就是等待时间太长了

  • 最短任务优先 -- SJF

    基于FCFS我们发现最好还是让短任务先执行,所以有了SJF最短任务优先策略。但它也不是完美的:

    1. 必须预先知道任务的执行时间,因此该策略使用场景有所限制;

    2. 表现严重依赖于任务到达时间。比如说在第1秒,任务A目前是最短的,所以开始执行任务A;此时第2秒时突然加入一个比A还短的任务B,但是此时调度器无法重新作出选择,所以B依旧需要阻塞等待A执行后才能开始。

说了那么多,说白了就是更新度太低了

  • 最短完成时间任务优先 -- STCF

    而STCF在任务到达系时会进行调度,可能会转而执行其他任务,即抢占型调度。前面说到的FCFS和SJF都属于非抢占式调度。虽然但是,它也是不完美的:

    1. 和SJF一样,它也需要预先知道任务的执行时间;

    2. 长任务饥饿。STCF更倾向于完成时间较短的任务,所以当一个系统中存在大量的短任务和长任务时,就会出现长任务永远无法调度的情况。

说白了就是偏心啦。

  • 时间片轮转 -- RR

    为任务设置时间片,限定每次任务执行的时间。当前任务执行完时间片后就切换到运行队列中的下一个队列。

(2)优先级调度

  • 静态优先级调度 -- 多级队列:MLQ

    每个任务会被分配预先设置好的优先级,每个优先级对应一个队列,任务就存储在队列中。

  • 动态优先级调度 -- 多级反馈队列:MLFQ

上面的多级队列策略只适合静态的场景。MLFQ在MLQ基础上增加了动态设置优先级的策略:

  1. 短任务拥有更高优先级

  2. 低优先级的任务采用更长的时间片

  3. 定时将所有任务的优先级提升到最高,类似于一个定时刷新。这就减少了任务饥饿出现的频率

(3)公平共享调度

公平共享调度会量化每个任务对系统资源的占有比例,从而实现对资源的公平调度。

2、多核调度策略

不想看了,浅摆一下。



五、进程间通信 IPC(*)

RPC:远程过程调用协议

一种通过网络从远程计算机上请求服务,而不需要了解底层网络技术的协议。

1、同步IPC和异步IPC

IPC的其中一种分类是分为同步IPC和异步IPC。两者不同就在于同步阻塞、异步非阻塞。

相比与异步IPC而言,同步IPC有着更好的抽象;但能够看得出来同步IPC的问题就在于并发的问题。目前的操作系统内核都会选择同时实现同步IPC和异步IPC。

2、套接字进程间通信

套接字(socket)是一种既可以用于本地又可以跨网络使用的通信机制。

套接字进程间通信可以使用不同协议比如:传输控制协议TCP用户数据报协议UDP

TCP相较而言可靠性更好,而UDP更加简单、在大多数场景下能够达到更好的传输性能。

TCP对应通信方式为数据流、UDP对应通信方式为数据报

插入一个TCP和UDP的比较

TCP是一种流模式的协议,UDP是一种数据报模式的协议

他们之间的不同在于:

  • UDP基于报文,在接收时每次最多只能读取一个报文,并且报文与报文间不会合并,发送端调用了几次write,接收端就要调用多少次read读完

  • TCP不一样,他没有一一对应的关系,你可以选择一次读完也可以选择分10次读完

这种截然不同的发送接收方式是由于TCP和UDP的特性决定的:TCP面向连接,socket中收到的数据都是由同一台主机发出的;而UDP是无连接,也就是说只要知道IP和端口并且网络可达,那么任何主机都可以向接收端发送数据。所以UDP一次最多只能接收一个报文,不然主机A和主机B同时发送报文如果能读取超过一个报文的数据,那俩报文就连一起了,这样的数据没啥意义。

Linux下创建一个套接字如下:

socket(int domain, int type, int protocol);
// domain:指定寻址方式(IP地址和本地路径)
// type:通信方式(数据流SOCK_STREAM和数据报SOCK_DGRAM)
// protocol:进一步定义协议相关的配置


六、同步原语

1、互斥锁

互斥锁:在多线程情况下防止两条线程同时对同一公共资源读写(会造成数据覆盖)的机制。

临界区指对一块公共资源进行存取的代码。解决临界区问题有三个条件:

  1. 互斥访问

  2. 有限等待

  3. 空闲让进

解决临界区问题可以抽象为以下几个方面:

  • 硬件实现:关闭中断

    中断:一种异步事件处理机制,可以提高操作系统的并发处理能力。为了避免中断处理程序时间过长和中断丢失的问题,Linux将中断分为了上下两部分(硬中断和软中断)

    • 硬中断:在中断禁止模式下运行,主要用来快速处理中断。

    • 软中断:以内核线程的方式运行,延迟处理上半部分没有完成的工作。

    单核环境中虽然线程不能并发,但是调度器可以调度线程在一个核心上交错执行,关闭中断就可以避免在这个问题。中断屏蔽的底层实现原理就是让CPU不响应中断。

    但是有一个问题,在多核环境中关闭中断依旧会出现临界区问题

  • 软件实现:皮特森算法

    算法实现如下:

/* i进程 */
flag[i] = TRUE;
turn = j;
while(flag[j] == TURE && turn == j);
// 临界区代码
flag[i] = FALSE;
// 其他代码

/* j进程 */
flag[j] = TRUE;
turn = i;
while(flag[i] == TURE && turn == i);
// 临界区代码
flag[j] = FALSE;
// 其他代码

算法的核心部分在于两个重要的变量:全局布尔数组flag和全局变量turn。

  • flag[]:用于标识对应的进程是否尝试进入临界区

  • turn:用于存放能够进入临界区的进程编号

举个例子,对于上面代码中的线程i而言,要想进入临界区,则必须符合条件 flag[j] = FALSE 或 turn = i。如果两个条件都不满足,则需要进入循环等待直到满足两个条件其中一个为止。

  • 软硬件协同:原子操作

    原子操作指的是不会被打断的一系列操作。常见的原子操作包括CAS( 比较与置换) 和FAA(拿取并累加),逻辑如下:

    // PS:以下代码不具备原子性,代码仅展示逻辑
    int CAS(int *addr, int expected, int new_value) {
        int tmp = *addr;
        if(*addr == expected) {
            *addr = new_value;
        }
        return tmp;
    }
    
    int FAA(int *addr, int add_value) {
        int tmp = *addr;
        *addr = tmp + add_value;
        return tmp;
    }

    互斥锁的种类繁多,下面说一下 基于CAS实现的自旋锁基于FAA实现的排号自旋锁

  1. 自旋锁

    void lock_init(int *lock) {
        // 初始化自旋锁
        *lock = 0;
    }
    
    void lock(int *lock) {
        while(atomic_CAS(lock, 0, 1) != 0) {
            // 循环忙等
        }
    }
    
    void unlock(int *lock) {
        *lock = 0;
    }

    自旋锁使用变量lock来标识锁的状态:1为有人拿锁、0为锁空闲。

    申请进入临界区的线程会尝试将lock由0 -> 1,因为我们使用原子CAS操作,所以在锁空闲的时候多个竞争者只会有一个成功完成CAS操作并获取锁从而进入临界区。

    但是自旋锁不公平,可以看得出看来自旋锁不是按照顺序来的,而是让所有竞争者均同时尝试完成原子操作,而原子操作的成功与否完全取决于硬件(小核运行频率低于大核),所以会出现小核进程抢不过大核进程的情况。

  2. 排号自旋锁

    上面说了自旋锁不公平,所以有了排号锁:排号锁根据锁竞争者申请锁的顺序传递锁,可以类拟为一个先进先出的等待队列

void lock_init(struct lock *lock) {
    // 初始化排号锁
    lock->owner = 0;
    lock->next = 0;
}

void lock(struct lock *lock) {
    //拿自己的序列号
    volatile int my_ticket = atomic_FAA(&lock->next, 1);
    while(lock->owner != my_ticket) {
        // 循环忙等
    }
}

void unlock(struct lock *lock) {
    // 传递给下一位竞争者
    lock->owner++;
}

结构体的两个成员:owner表示当前锁持有者的序号,next表示下一个需要分发的序号。


2、条件变量

  • 条件变量的使用

之前提到当一个线程没有得到锁时会陷入循环忙等。这忙等其实是没有必要的,不仅会增加系统的能耗,还占CPU资源。这时候就需要一个挂起/唤醒机制来避免这种能耗,条件变量由此而来。

条件变量提供两个接口:cond_waitcond_signal,分别用来挂起和唤醒线程

条件变量必须搭配一个互斥锁一起使用,其中互斥锁主要是用于保护对条件的判断和修改

下面是使用条件变量解决生产者消费者问题:

// 共享计数器 -- 信号量
int empty_slot = 5;
int filled_slot = 0;
// 条件变量
struct cond empty_cond;  	// 缓冲区无空位
struct cond filled_cond;	// 缓冲区无数据
// 两个互斥锁保护对上面两个计数器的修改
struct lock empty_cnt_lock;
struct lock filled_cnt_lock;

/* 生产者 */
void producter(void) {
    int new_msg;
    while(TRUE) {
        new_msg = producter_new();
        lock(&empty_cnt_lock);

        while(empty_slot == 0) { // 发现没有空闲位置
            cond_wait(&empty_cond, &empty_cnt_lock);	// 阻塞避免循环等待
        }

        empty_slot--;
        unlock(&empty_cnt_lock);

        buffer_add_safe(new_msg);   // 互斥锁保证对共享缓存操作的互斥性

        lock(&filled_cnt_lock);
        filled_slot++;	// 更新计数器
        cond_signal(&filled_cond);	// 唤醒等待在1处的的消费者(这里唤醒的是等待在filled_cond上的消费者)
        unlock(&filled_cnt_lock);
    }
}

/* 消费者 */
void consumer(void) {
    int cur_msg;
    while(TRUE) {
        lock(&filled_cnt_lock);
        while(&filled_slot == 0) {
            cond_wait(&filled_cond, &filled_cnt_lock);	// 1
        }

        filled_slot--;
        unlock(&filled_cnt_lock);

        cur_msg = buffer_remove_safe();     // 互斥锁保证对共享缓存操作的互斥性

        lock(&empty_cnt_lock);
        empty_slot++;		// 新空位产生
        cond_signal(&empty_cond);	// 唤醒等在empty_cond上的生产者
        unlock(&empty_cnt_lock);
        consume_msg(cur_msg);
    }
}
  • 条件变量的实现

接下来看看条件变量的一种实现:

每一个条件变量的结构体中都包含一个wait_list,用于记录等待在该条件变量上的线程。

/**
 * 条件变量的实现
 */
struct cond {
    struct thread *wait_list;
};

void cond_wait(struct cond *cond, struct lock *mutex) {
    // 将当前线程加入等待队列 
    list_append(cond->wait_list, thread_self());
    atomic_block_unlock(mutex);     // 原子挂起并放锁
    lock(mutex);    // 重新获得互斥锁
}

void cond_signal(struct cond *cond) {
    if(!list_empty(cond->wait_list)) {
        wakeup(list_remove(cond->wait_list));
    }
}

void cond_broadcast(struct cond *cond) {
    while(!list_empty(cond->wait_list)) {
        wakeup(list_remove(cond->wait_list));
    }
}

我们看到cond_wait。当线程调用cond_wait挂起自己的时候有两个步骤:

  1. 将当前线程加入等待队列;

  2. 原子地挂起线程,并释放锁。

这里的原子操作主要是由atomic_block_unlock操作完成挂起并放锁。这里的原子性很重要!!!

互斥锁VS条件变量

前面学习的互斥锁和条件变量本质上不是解决同一个问题

互斥锁用于解决临界区问题来保证互斥访问共享资源;而条件变量是通过提供“挂起/唤醒”机制来避免循环等待,从而节省CPU资源。

不过条件变量需要搭配互斥锁一起使用。



3、读写锁:增加读者间的并行度

读写锁主要是为了保证写共享数据的线程和读共享数据的线程不能同时进行。其实这个使用互斥锁也可以解决,但是读数据线程间可以并行,此时使用互斥锁会大大削减读进程之间的并发性。

  • 读写锁使用

    struct rwlock lock;
    char data[100];
    
    void reader(void) {
        lock_reader(&lock);
        read_data(data);    // 读临界区
        unlock_reader(&lock);
    }
    
    void writer(void) {
        lock_wriete(&lock);
        update_data(data);      // 写临界区
        unlock_write(&lock);
    }
  • 读写锁实现

假如在某时刻,临界区中已经存在了一些读者,此时一个读者和一个写者同时申请进入临界区。此时有两个情况:

  1. 允许读者直接进入临界区 --- 写者一直被阻塞直到没有任何读者,可能陷入无限等待(偏向读者)

  2. 等之前的读者离开临界区后先允许写者进入,再允许读者进入 --- 减少了读者的并发性(偏向写者)

1)偏向读者的读写锁实现

/**
 * @brief 偏向于读者的读写锁
 * 
 */
struct rwlock {
    int reader_cnt;		// 读者的计数器reader_cnt
    struct lock reader_lock;	// 控制读者对reader_cnt进行更新的互斥锁
    struct lock writer_lock;	// *保证 读+写、写+写 之间互斥的互斥锁*
};

void lock_reader(struct rwlock *lock) {
    lock(&lock->reader_lock);
    lock->reader_cnt++;
    if(lock->reader_cnt == 1) {     // 第一个读者 --> 需要获取writer_lock
        lock(&lock->writer_lock);	// 保证读者和写着互斥,阻塞后续写者
    }
    unlock(&lock->reader_lock);
}

void unlcok_reader(struct rwlock *lock) {
    lock(&lock->reader_lock);
    lock->reader_cnt--;
    if(lock->reader_cnt == 0) {		// 最后一个读者 --> 需要释放writer_lock
        unlock(&lock->writer_lock);	// 取消对写者的阻塞(后续读者在进入临界区时必须在此获取writer_lock)
    }
    unlock(&lock->reader_lock);
}

void lock_writer(struct rwlock *lock) {
    lock(&lock->writer_lock);
}

void unlock_writer(struct rwlock *lock) {
    unlock(&lock->writer_lock);
}

对于写者来说,只需在加锁和放锁时操作对应的writer_lock即可。因为当有读者在临界区时,writer_lock一定被读者拿着,所以只要操作writer_lock就可以保证和读者以及其他写者的互斥

2)偏向写者的读写锁实现

这个向对更复杂。

/**
 * @brief 偏向于写者的读写锁
 * 
 */
struct rwlock {
    volatile int reader_cnt;	
    volatile bool has_writer;	// 布尔变量表示当前是否有写者到达
    volatile lock lock;			// 读者和写者共享的锁 -- 要求读/写者操作元数据前要获取该锁
    volatile cond reader_cond;
    volatile cond writer_cond;
};

// 读者申请进入临界区
void lock_reader(struct rwlock *rwlock) {
    lock(&rwlock->lock);
    while(rwlock->has_writer == TRUE) {	// 发现有写者到达 -- 不能进入临界区
        cond_wait(&rwlock->writer_cond, &rwlock->lock);	// 条件变量挂起等待
    }
    // 无写者等待 -- 增加计数器,放锁
    rwlock->reader_cnt++;
    unlock(&rwlock->lock);
}

/**
读者放锁
*/
void unlock_reader(struct rwlock *rwlock) {
    lock(&rwlock->lock);
    rwlock->reader_cnt--;	// 读者计数器--
    if(rwlock->reader_cnt == 0) {	// 判断是否最后一个读者
        cond_signal(&rwlock->reader_cond);	// 唤醒等待在reader_cnt上的写者
    }
    unlock(&rwlock->lock);
}

// 写者申请进入临界区
void lock_writer(struct rwlock *rwlock) {
    lock(&rwlock->lock);
    while(rwlock->has_writer == TRUE) {	// 判断是否有其他写者
        cond_wait(&rwlock->reader_cond, &rwlock->lock);
    }
    // 没有写者
    rwlock->has_writer = TRUE;
    while(rwlock->reader_cnt > 0) {	// 等待之前所有读者离开临界区
        cond_wait(&rwlock->reader_cond, &rwlock->lock);
    }
    unlock(&rwlock->lock);	// 释放锁进入临界区
}

/**
写者放锁
*/
void unlock_writer(struct rwlock *rwlock) {
    lock(&rwlock->lock);
    rwlock->has_writer = FALSE;		// 设置标识器为false
    cond_broadcast(&rwlock->writer_cond);	// 将等待在has_writer上的读者和写者都唤醒
    unlock(&rwlock->lock);
}

相比偏读而言,偏写读写锁结构体增加了一个布尔值 -- has_writer来判断当前是否有写者到达。读者通过判断has_writer来决定是否需要等待前序的写者。

注意:

在写者放锁unlock_writer时需要使用cond_broadcast唤醒而非读者放锁中使用的cond_signal。因为使用cond_signal可能只唤醒了一个读者,那么等待在has_writer上的写者将永远不会被唤醒。

偏向读能提高并行度,但存在写延迟;偏向写解决了写延迟,但是并行度降低。

用的时候按序所取吧。


4、RCU:减少读者在关键路径上的性能开销

关键路径:设计中从输入到输出经过的延时最长的逻辑路径。

上面所说的读写锁虽然提高了读操作的并行度,但写者会阻塞读者,并且读者需要在关键路径上添加读写锁,造成性能开销。

RCU此时希望:

  1. 能多个读者进入临界区

  2. 不要阻塞读者

  3. 读者无需额外的同步原语来保护读临界区

也就是:随意读,但更新数据时,要先复制一份副本,在副本上完成修改,再一次性地替换旧数据。

更多细化参考:深入理解 Linux 的 RCU 机制 - 知乎

读写锁 VS RCU

同:都应对读多写少的场景,通过增加读者之间的并行度来提高性能。

异:RCU读者不会被写者阻塞,读者开销更小;不过读写锁的接口更方便。


5、同步带来的问题

  • (1)死锁

线程组中的每一个线程都在等待其他线程释放资源,你等我我等你,僵持不下从而陷入无限等待。

还有两种情况也会造成死锁:

  1. 在中断处理流程中使用互斥锁,在获取锁后、加锁前再次出现中断时;

  2. 在递归函数中使用互斥锁

上面这俩情况可以使用可重入锁解决问题。

可重入锁

加锁时会判断锁的持有者是否为线程自己,如果是则不会阻塞和等待,而是通过一个计数器来记录加锁次数。

这个计数器在放锁时会减少,当减0的时候才会真正放锁。

并且操作系统中断处理流程中,通常是“加锁时关闭中断,放锁时开中断”来解决这个问题。

  • (2)活锁

活锁和死锁机制很类似。只不过死锁是等不到资源就一直死等,而活锁是等不到资源就一直放手、重试、放手、重试.....

可采取以下方法解决问题:

  1. 让线程在获取失败后,等待一随机时间再开始下次尝试,来减少出现活锁的概率;

  2. 直接要求所有的线程都按照一个统一顺序来获取锁,避免出现活锁。

  • (3)优先级反转

用一个例子来解释优先级反转出现的具体情况:

假设现在在CPU核心上有高、中、低三个优先级的线程T1、T2、T3。调度器依照优先级高低进行调度。

其中高优先级线程T1和低优先级线程T3竞争同一把锁

解决方法协议:

  1. 不可抢占临界区协议 -- NCP

  2. 优先级继承协议 -- PIP

  3. 优先级置顶协议 -- PCP

    • 即时优先级置顶协议

    • 原生优先级置顶协议



七、文件系统

1、硬链接 -- 通过索引结点连接

Linux系统下分为硬连接和软链接。我们着重来看看硬链接:

Linux文件系统中保存在磁盘中的文件都会获得一个编号,即索引结点inode。

多个文件名指向同一索引节点是存在的,硬链接允许一个文件拥有多个有效路径名,这样就可以防止重要文件误删。只有当最后一个连接被删除时,文件的数据和目录的连接才会被释放。

ln file link	// ln硬链接操作:为文件file创建一个名字link

用户创建一个硬连接时,文件系统不会创建一新的inode,而是先找到目标文件的inode,随后在目标路径的父目录中增加一个指向此inode的新目录项。

2、软链接/符号链接 -- 通过文件路径连接

ln -s file slink	// ln -s创建软链接

软链接文件中保存的是一个字符串,表示一个文件路径,路径所对应文件为目标文件。

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

颜 然

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值