开源操作系统课程: https://learning.eulixos.com/eulixos-camp-book-stage2-docs/preface.html https://stozn.github.io/InterviewGuide/03-hunting_job/02-interview/03-03-net/

操作系统核心

操作系统核心知识体系可按核心功能模块与基础理论分类总结,具体如下:

一、基础概念与功能

  1. 核心定义:操作系统(OS)的角色(计算机硬件与软件的接口、资源管理器)、核心目标(提高资源利用率、提供用户友好接口、保障系统安全可靠)。
  2. 类型划分:批处理系统、分时系统、实时系统、分布式 OS、嵌入式 OS 等,及各类系统的设计目标与适用场景。
  3. 体系结构:单内核、微内核、混合内核(如 Windows NT)的结构差异与优缺点。

二、进程与线程管理

  1. 进程基础:进程的定义(程序的一次执行过程)、状态(就绪、运行、阻塞、终止)及状态转换机制;进程控制块(PCB)的组成与作用。
  2. 线程机制:线程与进程的区别(资源分配 vs 调度单位);用户级线程、内核级线程的实现差异;多线程模型(一对一、多对一、多对多)。
  3. 进程调度:调度目标(公平性、高效性、实时性);调度算法(FCFS、短作业优先、优先级调度、时间片轮转、多级反馈队列等)。
  4. 同步与互斥:临界区问题;同步机制(信号量、管程、互斥锁、条件变量);经典同步问题(生产者 - 消费者、读者 - 写者、哲学家进餐)。
  5. 死锁处理:死锁的 4 个必要条件(互斥、持有并等待、不可剥夺、循环等待);死锁的预防、避免(银行家算法)、检测与解除。

三、内存管理

  1. 内存分配:连续分配(单一连续、分区分配)、离散分配(分页、分段、段页式);页表、段表的结构与作用。
  2. 虚拟内存:定义(基于局部性原理的 “逻辑内存” 扩展);实现技术(请求分页、请求分段);页面置换算法(OPT、FIFO、LRU、CLOCK 等)。
  3. 内存保护与共享:地址重定位(静态、动态);内存访问权限控制;进程间内存共享机制。

四、存储与文件管理

  1. 文件系统:文件的定义(命名的字节序列)、属性(名称、类型、大小、权限等);文件逻辑结构(流式、记录式)与物理结构(连续、链接、索引)。
  2. 目录管理:目录结构(单级、多级、树形、图形);目录项的组成;路径(绝对路径、相对路径)。
  3. 外存管理:磁盘结构(磁道、扇区、柱面);磁盘调度算法(FCFS、SSTF、SCAN、CSCAN);RAID 技术(0-6 级的容错与性能特性)。

五、设备管理

  1. 设备分类:按特性(存储设备、I/O 设备)、按使用方式(独占设备、共享设备、虚拟设备)。
  2. I/O 控制方式:程序直接控制、中断驱动、DMA(直接内存访问)、通道控制。
  3. 设备分配:分配策略(静态、动态);设备独立性(逻辑设备与物理设备的映射);SPOOLing 技术(虚拟设备技术)。
  4. 中断处理:中断的分类(内中断、外中断);中断响应与处理流程(保存现场、执行中断服务程序、恢复现场)。

六、接口与系统调用

  1. 用户接口:命令接口(交互式命令、批处理命令)、图形用户接口(GUI)、程序接口(系统调用)。
  2. 系统调用:定义(用户程序请求内核服务的接口);类型(进程控制、文件操作、设备管理、内存管理等);执行过程(用户态→内核态切换、参数传递、结果返回)。

七、高级与扩展技术

  1. 并发与并行:并发(逻辑上同时)与并行(物理上同时)的区别;多核 CPU 下的进程 / 线程调度。
  2. 安全性:进程隔离、内存保护、用户权限管理(如 Unix 的 rwx 权限)。
  3. 虚拟化:虚拟机监控器(VMM)的作用;宿主 OS 与客户 OS 的交互;容器技术(如 Docker)的轻量级虚拟化原理。

以上体系覆盖了操作系统的核心功能(资源管理、进程调度、用户交互)与底层实现逻辑,是理解和掌握 OS 的基础框架。

并发编程

并发编程是处理多任务同时执行的编程范式,核心知识点可按 “基础概念 - 核心问题 - 解决机制 - 实践工具 - 高级拓展” 分类总结,具体如下:

一、基础概念与核心目标

  1. 并发与并行

    • 并发:多个任务 “逻辑上同时执行”(CPU 通过切换调度实现,如单核心 CPU 处理多线程)。
    • 并行:多个任务 “物理上同时执行”(多核心 CPU 同时处理多个任务)。
    • 核心区别:是否依赖硬件多核支持。
  2. 核心目标

    • 提高资源利用率(如 CPU、I/O 设备);
    • 提升程序响应速度(如 UI 界面与后台任务分离);
    • 支持多用户 / 多请求场景(如服务器处理并发请求)。

二、任务执行单元:进程、线程与协程

  1. 进程

    • 定义:操作系统分配资源的基本单位(拥有独立内存空间、文件描述符等)。
    • 特点:隔离性强(进程间通信需特殊机制,如管道、消息队列),创建 / 销毁开销大。
  2. 线程

    • 定义:进程内的执行单元(共享进程资源,如内存空间),是 CPU 调度的基本单位。
    • 特点:轻量(创建 / 销毁开销小于进程),但线程间共享资源可能导致安全问题。
    • 线程状态:就绪(等待调度)、运行(占用 CPU)、阻塞(等待资源,如 I/O)、终止。
  3. 协程(轻量级线程)

    • 定义:用户态的 “微线程”,由程序(而非内核)调度,切换开销极小(无需内核参与)。
    • 特点:适合高并发 I/O 场景(如网络请求),典型实现:Go 的 goroutine、Python 的 asyncio、Java 的 Project Loom。

三、核心问题:线程安全与并发风险

  1. 线程安全定义 多线程环境下,程序执行结果始终与单线程执行结果一致(即 “正确性不受并发影响”)。

  2. 风险来源

    • 共享资源竞争:多个线程同时操作共享变量(如全局变量、类成员变量)。
    • 竞态条件:程序结果依赖线程执行顺序(如 i++ 的非原子操作:读取 - 修改 - 写入三步可能被打断)。
    • 三大特性破坏
      • 可见性:一个线程修改的变量,其他线程不能立即看到(CPU 缓存导致);
      • 原子性:操作不能被打断(如 i++ 不是原子操作);
      • 有序性:指令重排序(编译器 / CPU 优化导致执行顺序与代码顺序不一致)。

四、同步与互斥:解决线程安全的核心机制

  1. 互斥锁(独占锁)

    • 作用:保证同一时间只有一个线程进入 “临界区”(访问共享资源的代码块)。
    • 实现:
      • 隐式锁:Java 的 synchronized(基于对象监视器,自动释放);
      • 显式锁:Java 的 ReentrantLock(需手动 lock()/unlock(),支持超时、中断)。
  2. volatile 关键字

    • 作用:保证变量的 “可见性”(修改后立即刷新到主内存,其他线程读取主内存最新值)和 “有序性”(禁止指令重排序)。
    • 局限:不保证原子性(如 volatile int i; i++ 仍不安全)。
  3. 原子类

    • 作用:通过 CAS(Compare-And-Swap)机制实现原子操作(无需加锁)。
    • 典型:Java 的 AtomicIntegerAtomicReference;C++ 的 std::atomic
  4. 高级同步工具

    • CountDownLatch:等待多个线程完成(如主线程等待所有子线程执行完毕)。
    • CyclicBarrier:多个线程到达屏障后再共同执行(如多阶段任务的阶段同步)。
    • Semaphore:控制并发访问资源的线程数(如限制连接池大小)。

五、并发容器:线程安全的数据结构

  1. 设计原则 避免手动加锁,通过内部同步机制(如分段锁、Copy-On-Write)保证线程安全。

  2. 典型容器

    • Map:Java 的 ConcurrentHashMap(分段锁 / CAS 优化);Python 的 concurrent.futures 中的线程安全字典。
    • List:Java 的 CopyOnWriteArrayList(写时复制,适合读多写少);
    • Queue:Java 的 ConcurrentLinkedQueue(无锁队列)、BlockingQueue(阻塞队列,用于生产者 - 消费者模型)。

六、线程池:并发任务的高效管理

  1. 核心作用 避免频繁创建 / 销毁线程的开销,控制并发线程数量(防止资源耗尽)。

  2. 核心参数

    • 核心线程数:长期驻留的线程;
    • 最大线程数:允许的最大线程数;
    • 任务队列:存放等待执行的任务;
    • 拒绝策略:任务队列满时的处理方式(如丢弃、抛出异常、由提交线程执行)。
  3. 典型线程池

    • Java:FixedThreadPool(固定线程数)、CachedThreadPool(动态扩容)、ScheduledThreadPool(定时任务);
    • Python:concurrent.futures.ThreadPoolExecutor
    • Go:sync.Pool(对象池,间接管理 goroutine)。

七、异步编程:非阻塞的并发模式

  1. 核心思想 任务执行不阻塞当前线程,通过回调、Future 等方式获取结果(适合 I/O 密集型任务)。

  2. 实现方式

    • 回调函数:任务完成后自动执行(易导致 “回调地狱”);
    • Future 模式:获取任务执行状态(如 Java 的 Future、Python 的 asyncio.Future);
    • CompletableFuture(Java)/async/await(Python/JS):简化异步代码,避免回调嵌套。

八、并发问题排查与优化

  1. 常见问题

    • 死锁:线程互相持有对方需要的锁(如哲学家进餐问题);
    • 活锁:线程不断重试但始终失败(如两个线程同时释放锁再获取);
    • 饥饿:低优先级线程长期得不到 CPU 资源。
  2. 排查工具

    • Java:jstack(查看线程栈,检测死锁)、jconsole(监控线程状态);
    • Linux:pstack(进程栈)、top -H(查看线程 CPU 占用)。
  3. 优化原则

    • 减少锁粒度(如 ConcurrentHashMap 的分段锁);
    • 避免不必要的同步(如使用 volatile 代替锁);
    • 优先使用无锁机制(如 CAS、原子类);
    • 区分 CPU 密集型(控制线程数≈核心数)与 I/O 密集型(线程数可大于核心数)任务。

总结

并发编程的核心是 “在提高效率的同时保证正确性”,需掌握:

  • 任务执行单元(进程 / 线程 / 协程)的特性;
  • 线程安全的三大特性(可见性 / 原子性 / 有序性)及破坏风险;
  • 同步机制(锁、原子类、并发工具)的适用场景;
  • 线程池、并发容器等实战工具的合理使用;
  • 异步编程与问题排查的实践技巧。

不同语言(如 Java、Go、Python)的实现细节不同,但核心思想相通,需结合具体场景选择合适的并发模型。

进程同步

在多道程序环境下,当程序并发执行时候,由于资源共享和进程间相互协作的关系,同一个系统中的诸进程之间会存在一下两种相互制约的关系:

  1. 间接相互制约。主要是资源共享这种情况

  2. 直接相互制约。源于进程间的相互合作,例如 A 进程向 B 进程提供数据,当 A 缓存没数据的时候 B 就阻塞,A 缓存满时 A 就阻塞。

进程同步首先要搞明白临界区

  • 临界区

许多硬件资源如打印机,磁带机等,都属于临界资源,诸进程应该采取互斥方式,实现对这种资源的共享。人们把在每个进程中访问临界资源的那段代码成为临界区,显然,若能保证诸进程互斥地进入自己的临界区,便可实现诸进程对临界资源的互斥访问。

  • 同步机制遵循的原则
  1. 空闲让进,当无进程处于临界区时,表明临界资源处于空闲状态,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效的利用临界资源。

  2. 忙则等待,当已有进程进入临界区时,表明临界资源正在被访问,因而其他视图进入临界区的进程必须等待,以保证对临界资源的互斥访问。

  3. 有限等待,对要求访问临界资源的进程 ,应保证在有限时限内能进入自己的临界区,以免陷入死等状态。

  4. 让权等待,当进程不能进入自己的临界区时,应立即释放处理机,以免进程陷入忙等状态。

  • 进程同步实现机制
  1. 提高临界区代码执行中断的优先级

在传统操作系统中,打断进程对临界区代码的执行只有中断请求、中断一旦被接受,系统就有可能调用其它进程进入临界区,并修改此全局数据库。所以用提高临界区中断优先级方法就可以屏蔽了其它中断,保证了临界段的执行不被打断,从而实现了互斥。

  1. 自旋锁

内核(处理器也如此)被保持在过渡状态“旋转”,直到它获得锁,“自旋锁”由此而得名。

自旋锁像它们所保护的数据结构一样,储存在共用内存中。为了速度和使用任何在处理器体系下提供的锁定机构,获取和释放自旋锁的代码是用汇编语言写的。

  1. 信号量机制

跟进程同步信号量机制一样

  • 经典进程同步问题

具体参考

  1. 生产者——消费者问题

  2. 哲学家进餐问题

  3. 读者——写者问题

经典的进程间通信 (同步) 问题

P 是减小

V 是增加

生产者—消费者

解决方案:

 
pthread_mutex_t mutex;
 
pthread_cond_t producter;
 
pthread_cond_t consumer;
 
count = pool.size()
 
  
 
producer(){
 
while(1){
 
pthread_mutex_lock(&mutex);
 
pool++;
 
while(pool == full){
 
pthread_cond_wait(&producter, &mutex);
 
}
 
pthread_cond_signal(&consumer, &mutex);
 
pthread_mutex_unlock(&mutex);
 
}
 
}
 
consumer(){
 
while(1){
 
pthread_mutex_lock(&mutex);
 
pool--;
 
while(pool == 0){
 
pthread_cond_wait(&consumer, &mutex);
 
}
 
pthread_cond_signal(&producter, &mutex);
 
pthread_mutex_unlock(&mutex);
 
}
 
}
 

注意不能先执行 P(mutex) 再执行 P(empty),这样生产者阻塞了消费者也阻塞了。

哲学家就餐问题

整个模型中有五个进程,互相之间是互斥的关系。解决方法是:

  1. 同时拿到两个筷子

  2. 对每个哲学家的动作制定规则,避免饥饿或者死锁。

解决方法是设置一个互斥信号量组用于对进程之间的互斥访问 chopstick=[1,1,1,1,1]

 

semaphore chopstick[5] = {1,1,1,1,1}; //定义信号量数组 mutex[5],并初始化

Pi(){ //i 号哲学家的进程

do{

P (chopstick[i] ) ; //取左边筷子

P (chopstick[(i+1) %5] ) ; //取右边篌子

eat; //进餐

V(chopstick[i]) ; //放回左边筷子

V(chopstick[(i+l)%5]); //放回右边筷子

think; //思考

} while (1);

}

这个算法存在以下问题:同时就餐拿起左边的筷子会产生死锁

改进:

对哲学家进程施加一些限制条件,比如至多允许四个哲学家同时进餐; 仅当一个哲学家左右两边的筷子都可用时才允许他抓起筷子

 

semaphore chopstick[5] = {1,1,1,1,1}; //初始化信号量

semaphore mutex=l; //设置取筷子的信号量

Pi(){ //i 号哲学家的进程

do{

P (mutex) ; //在取筷子前获得互斥量

P (chopstick [i]) ; //取左边筷子

P (chopstick[ (i+1) %5]) ; //取右边筷子

V (mutex) ; //释放取筷子的信号量

eat; //进餐

V(chopstick[i] ) ; //放回左边筷子

V(chopstick[ (i+l)%5]) ; //放回右边筷子

think; // 思考

}while(1);

}

读写问题

问题中的三个关系:

  1. 读者和写者是互斥关系

  2. 写者和写者是互斥关系

  3. 读者和读者不存在互斥关系

有读者和写者

  1. 写者好分析,他和任意进程互斥,用互斥信号量操作就行
  1. 读者问题比较复杂。读者必须实现和写者的互斥,而且要实现和其他读者的同步。

因此用到一个计数器,判断当前有多少读者在读文件。

当有读者的时候不能写入文件,当没有读者的时候写者才会写入文件。

同时计数器也是公共内存,对计数器的访问也应该是互斥的

 

int count=0; //用于记录当前的读者数量

semaphore mutex=1; //用于保护更新 count 变量时的互斥

semaphore rw=1; //用于保证读者和写者互斥地访问文件

//可以看到 writer 比较简单

writer () { //写者进程

while (1){

P(rw); // 互斥访问共享文件

Writing; //写入

V(rw) ; //释放共享文件

}

}

reader () { // 读者进程

while(1){

P (mutex) ; //互斥访问 count 变量

if (count==0) //当第一个读进程读共享文件时

P(rw); //阻止写进程写

count++; //读者计数器加 1

V (mutex) ; //释放互斥变量 count

reading; //读取

P (mutex) ; //互斥访问 count 变量

count—; //读者计数器减 1

if (count==0) //当最后一个读进程读完共享文件

V(rw) ; //允许写进程写

V (mutex) ; //释放互斥变量 count

}

}

操作系统是如何做到进程阻塞的?

  • 什么是阻塞

正在运行的进程由于提出系统服务请求(如 I/O 操作),但因为某种原因未得到操作系统的立即响应,或者需要从其他合作进程获得的数据尚未到达等原因,该进程只能调用阻塞原语把自己阻塞,等待相应的事件出现后才被唤醒。

进程并不总是可以立即运行的,一方面是 CPU 资源有限,另一方面则是进程时常需要等待外部事件的发生,例如 I/O 事件、定时器事件等。

  • 如何做到进程阻塞

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务,这里用到了时间片轮转技术,如果在时间片结束时进程还在运行,则 CPU 使用权将被剥夺并分配给另一个进程。多级反馈队列调度算法

当 CPU 从一个进程跑到了别的进程之后,肯定还需要跑回来,因此就有工作队列和等待队列。

假如现在进程 A 里跑的程序有一个对象执行了某个方法将当前进程阻塞了,内核会立刻将进程 A 从工作队列中移除,同时创建等待队列,并新建一个引用指向进程 A。这时进程 A 被排在了工作队列之外,不受系统调度了,这就是我们常说的被操作系统“挂起”。这也提现了阻塞和挂起的关系。阻塞是人为安排的,让你程序走到这里阻塞。而阻塞的实现方式是系统将进程挂起。

当这个对象受到某种“刺激”(某事件触发)之后, 操作系统将该对象等待队列上的进程重新放回到工作队列上就绪,等待时间片轮转到该进程。所以,操作系统不会去尝试运行被阻塞的进程,而是由对象去等待某种“刺激”,喜欢被动。

(要点:时间片轮转,工作队列和等待队列)

  • 进程阻塞不消耗 CPU 资源,但是会消耗系统资源。因此系统资源不仅包含 CPU,还有内存、磁盘 I/O 等等

线程间通信的方式

  • 互斥锁。确保同一时间内只有一个线程能访问共享资源。当资源被占用时其他试图加锁的线程会进入阻塞状态。当锁释放后,哪个线程能上锁取决于内核调度。

  • 读写锁。当以写模式加锁的时候,任何其他线程不论以何种方式加锁都会处以阻塞状态。当以读模式加锁时,读状态不阻塞,但是写状态阻塞。“读模式共享,写模式互斥”

  • 自旋锁。上锁受阻时线程不阻塞而是在循环中轮询查看能否获得该锁,没有线程的切换因而没有切换开销,不过对 CPU 的霸占会导致 CPU 资源的浪费。

  • 锁机制

信号量本质上是一个计数器,可以有 PV 操作,来控制多个进程或者线程对共享资源的访问。

信号量 API 有两组,第一组就是 System V IPC 信号量用于进程间通信的,另外一组就是 POSIX 信号量,信号量原理都是一样的

  • posix 信号量机制

条件变量提供了线程间的通知机制:当某个共享数据到达某个值的时候,唤醒等待这个共享数据的线程。

条件变量要结合互斥锁使用,如下图代码:

 
pthread_mutex_lock(&mutex); 
 
  while(条件为假)
 
    pthread_cond_wait(&cond,&mutex);  
 
  执行某种操作
 
pthread_mutex_unlock(&mutex);
 

也就是说,一个线程要等到临界区的共享数据达到某种状态时再进行某种操作,而这个状态的成立,则是由另外一个进程/线程来完成后发送信号来通知的。

线程是如何实现的?

这个看《操作系统》总结的也有

实现线程主要有三种方式:(1)使用内核线程实现,(2)使用用户线程实现(3)使用用户线程加轻量级进程混合实现。

  • 内核线程实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这种操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口:轻量级进程(Light Weight Process ,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。

  • 用户线程实现

从广义上讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)。而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,因此比较难。

  • 用户线程加轻量级进程混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。

线程调度

  • 协同式调度

使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上。协同式多线程的最大好处是实现简单,不会有线程同步问题。缺点是线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

  • 抢占式调度

使用抢占式调度的多线程系统,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。

Linux 内核的同步方式

首先要明确,同步和互斥是计算机系统中,用于控制进程对某些特定资源访问的机制。同步指的是进程按照一定的顺序和规则访问资源,互斥则是控制资源某一时刻只能由一个进程访问。这样看来互斥是同步的一种情况。

在现代操作系统里,同一时间可能有多个内核执行流在执行,因此内核其实象多进程多线程编程一样也需要一些同步机制来同步各执行单元对共享数据的访问。尤其是在多处理器系统上,更需要一些同步机制来同步不同处理器上的执行单元对共享的数据的访问。在主流的 Linux 内核中包含了几乎所有现代的操作系统具有的同步机制,进程阻塞为什么不占用 cpu 资源?

linux 会因为三种机制而产生并发,需要进行同步。

  1. **中断。**中断就是操作系统随时可以打断正在执行的代码。当进程访问某个临界资源发生中断,会进入中断处理程序。中断处理程序也可能访问该临界资源。
  1. **内核抢占式调度。**内核中正在执行的任务被另外一个任务抢占。
  1. **多处理器并发。**每个处理器都可以调度一个进程,多个进程可能会造成并发。
  • 这些同步机制包括:原子操作、信号量(semaphore)、读写信号量 (rw_semaphore)、spinlock、BKL(Big Kernel Lock)、rwlock、brlock(只包含在 2.4 内核中)、RCU(只包含在 2.6 内核中)和 seqlock(只包含在 2.6 内核中)。

对于单处理器不可抢占系统来说,系统并发源主要是中断处理。因此在进行临界资源访问时,进行禁用/使能中断即可以达到消除异步并发源的目的。

  • 禁用中断

原子操作保证指令在运行时候不会被任何事物或者事件打断,把读和写的行为包含在一步中执行,避免竞争。

  • 原子操作

程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段:

  1. 编译时,编译器优化导致内存乱序访问(指令重排)

  2. 运行时,多 CPU 间交互引起内存乱序访问

  • 内存屏障 (memory barrier)

对于复杂的操作原子操作是不能的。比如从一种数据结构中读取数据,写入到另一种数据结构中。自旋锁是 linux 内核中最常见的一种锁。自旋锁只能被一个可执行线程所拥有,如果有线程要抢占一个已经被占有的自旋锁,则其会一直进行循环来等待锁重新可用。当锁被释放后,请求锁的执行线程便能立刻得到它。其实就是忙等自旋锁只适用于那些在临界区停留很短时间的加锁操作。因为线程在等待锁期间会一直占据处理器,如果长时间等待锁会导致处理器效率降低。而如果线程占用锁只需要短暂的执行一下,那么使用自旋锁更优,因为不需要进行上下文的切换。

  • 自旋锁 (spin lock)

可以理解为一种“睡眠锁”

自旋锁是“忙等”机制,在临界资源被锁定的时间很短的情况下很有效。但是在临界资源被持有时间很长或者不确定的情况下,忙等机制则会浪费很多宝贵的处理器时间。

而信号量机制在进程无法获取到临界资源的情况下,立即释放处理器的使用权,并睡眠在所访问的临界资源上对应的等待队列上;在临界资源被释放时,再唤醒阻塞在该临界资源上的进程。信号量适用于长时间占用锁的情形。

  • 信号量

具体就是在读写场景中了,为读和写提供不同的锁机制。

当一个或者多个读任务时可以并发的持有读锁,但是写锁只能被一个人物所持有的。即对写者共享对读者排斥

  • 读 - 写自旋锁

这个和读写自旋锁的思想是一样的。

  • 读写信号量

也是一种睡眠锁,是实现互斥的特定睡眠锁。是一种互斥信号

使用 mutex 有很多限制,不像信号量那样想用就用

  1. 任何时刻只有一个任务可以持有 mutex,引用计数只能是 1

  2. 给 mutex 上锁必须给其解锁,严格点就是必须在同一上下文中上锁和解锁

  3. 持有 mutex 的进程不能退出

  4. 等等

  • mutex 体制

内核中一个任务需要发出信号通知另一个任务发生了某个特定事件,利用完成变量使得两个任务实现同步

  • 完成变量 (completion variable)

属于混沌时期的产物,是一个全局的自旋锁

这个东西是早期 linux 不支持线程的时候用的,和自旋锁差不多的思想。

现在一般不鼓励使用这个了

  • BLK:大内核锁

这种锁用于读写共享数据。

实现这个机制主要依靠序列计数器,当写数据时,会得到一个锁,序列值会增加。在读取数据前后这两个时间内,序列值都会被读取

如果读取的序列号值相同,表明读操作在进行的过程中没有被写操作打断,

  • 顺序锁

内核是抢占性的,因此内核中的进程在任何时候都可能停下来以便让另一个具有更高优先级的进程运行。但是一个任务和被抢占的任务可能在同一个临界区运行。为了避免上述情况,当使用自旋锁时这个区域被标记为非抢占的,一个自旋锁被持有表示内核不能抢占调度。

但是在一些情况下,就算不用自旋锁,也要关闭内核抢占。

比如,对于只有一个处理器能够访问到数据,原理上是没有必要加自旋锁的,因为在任何时刻数据的访问者永远只有一位。但是,如果内核抢占没有关闭,则可能一个新调度的任务就可能访问同一个变量。

所以这时候害怕的不是多个任务访问同问同一个变量,而是一个任务的访问还没有完成就转到了另一个任务。

  • 关闭内核抢占

RCU(Read, Copy, Update) 是一组 Linux 内核 API,实现了一种同步机制,允许多个读者与写者并发操作而不需要任何锁,这种同步机制可以用于保护通过指针进行访问的数据。比较适合用在读操作很多而写操作极少的情况,可以用来替代读写锁。

一个典型场景就是内核路由表,路由表的更新是由外部触发的,外部环境的延迟远比内核更新延迟高,在内核更新路由表前实际已经向旧路径转发了很多数据包,RCU 读者按照旧路径再多转发几个数据包是完全可以接受的,而且由于 RCU 的无锁特性,实际上相比有锁的同步机制,内核可以更早生效新的路由表。路由表这个场景,以系统内部短时间的不一致为代价,降低了系统内部与外部世界的不一致时间,同时降低了读者成本。

Q: RCU(Read, Copy, Update)

A: 任何 CPU 架构下的 Linux 都可以保证指针操作的原子性,这是无锁并发的前提。也就是说,假设 CPU A 在修改指针,无论何时 CPU B 读取该指针,都可以保证读取到的数据要么是旧的值,要么是新的值,绝不会是混合新旧值不同 bit 位的无意义值。因此使用 RCU 对更复杂的数据结构的保护都是基于对指向该数据结构的指针的保护。

进程虚拟空间是怎么布局的?进程内存模型

linux 进程在 32 位处理器下的虚拟空间内存布局,从高地址到低地址

  • 内核空间,从 C000000-FFFFFFFF

  • 栈区。有以下用途

  1. 存储函数局部变量

  2. 记录函数调用过程的相关信息,成为栈帧,主要包括函数返回地址,一些不适合放在寄存器中的函数参数。

  3. 临时存储区,暂存算术表达式的计算结果和 allocation 函数分配的栈内存。

  • 内存映射段(mmap)。内核将硬盘文件的内容直接映射到内存,是一种方便高校的文件 I/O 方式,用于装在动态共享库。

  • 堆。分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。

  • BSS 段。通常存放以下内容:

  1. 未初始化的全局变量和静态局部变量

  2. 初始化值为 0 的全局变量和静态局部变量

  3. 未定义且初值不为 0 的符号

  • 数据段。通常存放程序中已经初始化且初值不为 0 的全局变量。数据段属于静态存储区,可读可写。

数据段和 BSS 段的区别如下:

  1. BSS 段不占用物理文件尺寸,但占用内存空间(不在可执行文件中)。数据段在可执行文件中,也占用内存空间。

  2. 当程序读取数据段的数据时候,系统会发生缺页故障,从而分配物理内存。当程序读取 BSS 段数据的时候,内核会将其转到一个全零页面,不会发生缺页故障,也不会为期分配物理内存。

  3. bss 是不占用.exe 文件(可执行文件)空间的,其内容由内存为什么要分堆栈在编程里,要是全部只用堆或者全部只用栈可以吗(清零);而 data 却需要占用,其内容由操作系统初始化,因此造成了上述情况。

  • 代码段。代码段也称正文段或文本段,通常用于存放程序执行代码 (即 CPU 执行的机器指令)。一般 C 语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。

  • 保留区。位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。

系统调用和进程上下文切换的区别

系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:

进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。

上下文切换

程序初始化

  • CPU 寄存器是 CPU 内置的容量小、但速度极快的内存。
  • 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。

什么是 CPU 上下文切换

就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

CPU 上下文切换的类型

  • CPU 寄存器和程序计数器就是 CPU 上下文,因为它们都是 CPU 在运行任何任务前,必须的依赖环境。
  1. .切换页目录以使用新的地址空间

  2. 切换内核栈

  3. 切换硬件上下文

  4. 刷新 TLB

  5. 系统调度器的代码执行

  • 进程上下文切换

线程上下文切换

前后两个线程属于不同进程。此时,因为资源不共享,所以切换过程就跟进程上下文切换是一样。

两个线程属于不同进程

线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,

但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。

内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。

简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。

  • 两个线程属于相同进程

为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

中断上下文切换。即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。只需要关注内核资源就行,CPU 寄存器,内核堆栈,硬件中断参数啥的。

  • 中断上下文切换并不涉及到进程的用户态
  1. 时间片轮转技术下,该进程分配到的时间片耗尽,就会被系统挂起,切换到其他进程

  2. 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。

  3. 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。

  4. 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行

  5. 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

什么是大端字节,什么是小端字节?如何转换字节序?

大端序,小端序是计算机存储数据的两种方式。

大端字节序:高位字节在前,低位字节在后,符合人类读写数值的习惯(参考普通的十进制数)

小端字节序:低位字节在前,高位字节在后。

  • 为什么要有大小端字节序?统一不行吗?

答:①内存的低地址处存放低字节,所以在强制转换数据时不需要调整字节的内容(注解:比如把 int 的 4 字节强制转换成 short 的 2 字节时,就直接把 int 数据存储的前两个字节给 short 就行,因为其前两个字节刚好就是最低的两个字节,符合转换逻辑);②CPU 做数值运算时从内存中依顺序依次从低位到高位取数据进行运算,直到最后刷新最高位的符号位,这样的运算方式会更高效。但是大端序更符合人类的习惯,主要用在网络传输和文件存储方面,符号位在所表示的数据的内存的第一个字节中,便于快速判断数据的正负和大小。

其各自的优点就是对方的缺点,正因为两者彼此不分伯仲,再加上一些硬件厂商的坚持,因此在多字节存储顺序上始终没有一个统一的标准

  • 转换字节序

主要是针对主机字节序和网络字节序来说的。我们用的 x86 架构的处理器一般都是小端序存储数据,但是网络字节序是 TCP/IP 中规定好的数据表示格式,RFC1700 规定使用“大端”字节序为网络字节序,独立于处理器操作系统。

在 Linux 网络编程中,会使用下列 C 标准库函数进行字节之间的转换

 
#include <arpa/inet.h>
 
uint32_t htonl(uint32_t hostlong); //把uint32_t类型从主机序转换到网络序
 
uint16_t htons(uint16_t hostshort); //把uint16_t类型从主机序转换到网络序
 
uint32_t ntohl(uint32_t netlong); //把uint32_t类型从网络序转换到主机序
 
uint16_t ntohs(uint16_t netshort); //把uint16_t类型从网络序转换到主机序
 
  • 如何判断本机是大端序还是小端序?
 
int i=1;
 
char *p=(char *)&i;
 
if(*p == 1)
 
printf("小端模式"); //小端序是低位字节在前,高位字节在后
 
else // (*p == 0)
 
printf("大端模式");
 

mmap(内存映射)

什么是 mmap?

mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read,write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

Mmap 的过程—原理

  1. 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

  2. 调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

  3. 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

Mmap 和常规文件操作的区别

常规文件的操作如下:

  1. 进程发起读文件请求。

  2. 内核查找文件描述符,定位到内核已打开的文件信息,找到文件的 inode。

  3. 查看文件页是否在缓存中,如果存在则直接返回这片页面

  4. 如果不存在,缺页中断,需要定位到该文件的磁盘地址处,将数据从磁盘复制到页缓存中,然后发起页面读写过程,将页缓存中的数据发送给用户

常规文件需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。

而使用 mmap 操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。

Malloc 是如何实现内存管理的

参考链接

C 语言中使用 malloc 可以分配一段连续的内存空间。在 c/c++ 开发中,因为 malloc 属于 C 标准库函数,经常会使用其分配内存。malloc 是在堆中分配一块可用内存给用户。作为一个使用频繁的基础函数,理解清楚其实现原理很有必要,因此本文主要探讨 malloc 的具体实现原理,以及在 linux 系统中这该函数的实现方式。

内存分配涉及到的系统调用

共享内存不保证同步,可以使用信号量来保证共享内存同步

上图是一个堆内存的分布,其实在堆内存有三段空间,第一段是映射好的,指针可以访问的;第二段是未映射的地址空间,访问这段空间会报错;第三段是无法使用的空间。其中映射好的空间和未映射的空间由一个 brk 指针分割。所以如果要增加实际堆的可用大小,就可以移动 brk 指针。

跟 brk/sbrk/mmap 这三个系统调用函数有关

要增加一个进程实际的可用堆大小,就需要将 break 指针向高地址移动。Linux 通过**brk()和sbrk()**操作 break 指针。两个系统调用的原型如下:

 
#include <unistd.h>
 
int brk(void *addr);
 
void *sbrk(intptr_t increment);
 

brk 函数将 break 指针直接设置为某个地址,而 sbrk 将 break 指针从当前位置移动 increment 所指定的增量。所以我们使用 sbrk 获得 brk 的地址。brk 在执行成功时返回 0,否则返回 -1 并设置 errno 为 ENOMEM;sbrk 成功时返回 break 指针移动之前所指向的地址,否则返回 (void *)-1。

**mmap 函数:**mmap 函数第一种用法是映射磁盘文件到内存中;而 malloc 使用的 mmap 函数的第二种用法,即匿名映射,匿名映射不映射磁盘文件,而是向映射区申请一块内存。当申请小内存的时,malloc 使用 sbrk 分配内存;当申请大内存时,使用 mmap 函数申请内存;但是这只是分配了虚拟内存,还没有映射到物理内存,当访问申请的内存时,才会因为缺页异常,内核分配物理内存。

Malloc 实现方案

由于 brk/sbrk/mmap 属于系统调用,如果每次申请内存,都调用这三个函数中的一个,那么每次都要产生系统调用开销(即 cpu 从用户态切换到内核态的上下文切换,这里要保存用户态数据,等会还要切换回用户态),这是非常影响性能的;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果低地址的内存没有被释放,高地址的内存就不能被回收。鉴于此,brk 和 sbrk 系统调用,malloc 内存池实现方式更类似于 STL 分配器和 memcached 的内存池,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块即可。

如上图,内存池保存在 bins 这个长 128 的数组中,每个元素都是一双向个链表。

malloc 将内存分成了大小不同的 chunk,然后通过 bins 来组织起来。malloc 将相似大小的 chunk(图中可以看出同一链表上的 chunk 大小差不多)用双向链表链接起来,这样一个链表被称为一个 bin。malloc 一共维护了 128 个 bin,并使用一个数组来存储这些 bin。数组中第一个为malloc 采用的是内存池的实现方式,数组编号前 2 到前 64 的 bin 为unsorted bin,同一个 small bin 中的 chunk 具有相同的大小,两个相邻的 small bin 中的 chunk 大小相差 8bytes。small bins 后面的 bin 被称作small bins。large bins 中的每一个 bin 分别包含了一个给定范围内的 chunk,其中的 chunk 按大小序排列。large bin 的每个 bin 相差 64 字节。

malloc 除了有 unsorted bin,small bin,large bin 三个 bin 之外,还有一个large bins。一般的情况是,程序在运行时会经常需要申请和释放一些较小的内存空间。当分配器合并了相邻的几个小的 chunk 之后,也许马上就会有另一个小块内存的请求,这样分配器又需要从大的空闲内存中切分出一块,这样无疑是比较低效的,故而,malloc 中在分配过程中引入了 fast bins,不大于 max_fast(默认值为 64B) 的 chunk 被释放后,首先会被放到 fast bins 中,fast bins 中的 chunk 并不改变它的使用标志 P。这样也就无法将它们合并,当需要给用户分配的 chunk 小于或等于 max_fast 时,malloc 首先会在 fast bins 中查找相应的空闲块,然后才会去查找 bins 中的空闲 chunk。在某个特定的时候,malloc 会遍历 fast bins 中的 chunk,将相邻的空闲 chunk 进行合并,并将合并后的 chunk 加入 unsorted bin 中,然后再将 unsorted bin 里的 chunk 加入 bins 中。

unsorted bin 的队列使用 bins 数组的第一个,如果被用户释放的 chunk 大于 max_fast,或者 fast bins 中的空闲 chunk 合并后,这些 chunk 首先会被放到 unsorted bin 队列中,在进行 malloc 操作的时候,如果在 fast bins 中没有找到合适的 chunk,则 malloc 会先在 unsorted bin 中查找合适的空闲 chunk,然后才查找 bins。如果 unsorted bin 不能满足分配要求。 malloc 便会将 unsorted bin 中的 chunk 加入 bins 中。然后再从 bins 中继续进行查找和分配过程。从这个过程可以看出来,fast bin(其实感觉在这里还利用了局部性原理,常用的内存块大小差不多,从 unsorted bin 这里取就行了,这个和 TLB 之类的都是异曲同工之妙啊!)

除了上述四种 bins 之外,malloc 还有三种内存区。

  • 当 fast bin 和 bins 都不能满足内存需求时,malloc 会设法在**unsorted bin 可以看做是 bins 的一个缓冲区,增加它只是为了加快分配的速度。**中分配一块内存给用户;top chunk 为在 mmap 区域分配一块较大的空闲内存模拟 sub-heap。(比较大的时候) >top chunk 是堆顶的 chunk,堆顶指针 brk 位于 top chunk 的顶部。移动 brk 指针,即可扩充 top chunk 的大小。当 top chunk 大小超过 128k(可配置) 时,会触发 malloc_trim 操作,调用 sbrk(-size) 将内存归还操作系统。

  • 当 chunk 足够大,fast bin 和 bins 都不能满足要求,甚至 top chunk 都不能满足时,malloc 会从 mmap 来直接使用内存映射来将页映射到进程空间,这样的 chunk 释放时,直接解除映射,归还给操作系统。(极限大的时候)

  • Last remainder 是另外一种特殊的 chunk,就像 top chunk 和 mmaped chunk 一样,不会在任何 bins 中找到这种 chunk。当需要分配一个 small chunk,但在 small bins 中找不到合适的 chunk,如果 last remainder chunk 的大小大于所需要的 small chunk 大小,last remainder chunk 被分裂成两个 chunk,其中一个 chunk 返回给用户,另一个 chunk 变成新的 last remainder chunk。(这个应该是 fast bins 中也找不到合适的时候,用于极限小的)

由之前的分析可知 malloc 利用 chunk 结构来管理内存块,malloc 就是由不同大小的 chunk 链表组成的。malloc 会给用户分配的空间的前后加上一些控制信息,用这样的方法来记录分配的信息,以便完成分配和释放工作。chunk 指针指向 chunk 开始的地方,图中的 mem 指针才是真正返回给用户的内存指针。

Malloc 内存分配流程

  1. 如果分配内存<512 字节,则通过内存大小定位到 smallbins 对应的 index 上 (floor(size/8))
  • 如果 smallbins[index] 为空,进入步骤 3

  • 如果 smallbins[index] 非空,直接返回第一个 chunk

  1. 如果分配内存>512 字节,则定位到 largebins 对应的 index 上
  • 如果 largebins[index] 为空,进入步骤 3

  • 如果 largebins[index] 非空,扫描链表,找到第一个大小最合适的 chunk,如 size=12.5K,则使用 chunk B,剩下的 0.5k 放入 unsorted_list 中

  1. 遍历 unsorted_list,查找合适 size 的 chunk,如果找到则返回;否则,将这些 chunk 都归类放到 smallbins 和 largebins 里面

  2. index++ 从更大的链表中查找,直到找到合适大小的 chunk 为止,找到后将 chunk 拆分,并将剩余的加入到 unsorted_list 中

  3. 如果还没有找到,那么使用 top chunk

  4. 或者,内存<128k,使用 brk;内存>128k,使用 mmap 获取新内存

此外,调用 free 函数时,它将用户释放的内存块连接到空闲链上。到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段,那么空闲链上可能没有可以满足用户要求的片段了。于是,malloc 函数请求延时,并开始在空闲链上翻箱倒柜地检查各内存片段,对它们进行整理,将相邻的小空闲块合并成较大的内存块。

内存碎片

free 释放内存时,有两种情况:

  1. chunk 和 top chunk 相邻,则和 top chunk 合并

  2. chunk 和 top chunk 不相邻,则直接插入到 unsorted_list 中

img

如上图示: top chunk 是堆顶的 chunk,堆顶指针 brk 位于 top chunk 的顶部。移动 brk 指针,即可扩充 top chunk 的大小。当 top chunk 大小超过 128k(可配置) 时,会触发 malloc_trim 操作,调用 sbrk(-size) 将内存归还操作系统。

以上图 chunk 分布图为例,按照 glibc 的内存分配策略,我们考虑下如下场景 (假设 brk 其实地址是 512k):

malloc 40k 内存,即 chunkA,brk = 512k + 40k = 552k malloc 50k 内存,即 chunkB,brk = 552k + 50k = 602k malloc 60k 内存,即 chunkC,brk = 602k + 60k = 662k free chunkA。

此时,由于 brk = 662k,而释放的内存是位于 [512k, 552k] 之间,无法通过移动 brk 指针,将区域内内存交还操作系统,因此,在 [512k, 552k] 的区域内便形成了一个内存空洞即内存碎片。 按照 glibc 的策略,free 后的 chunkA 区域由于不和 top chunk 相邻,因此,无法和 top chunk 合并,应该挂在 unsorted_list 链表上。

信号量和互斥量的区别?

我觉得主要区别在两方面:top chunk

  • 先说第一个所有权。

解铃还须系铃人,一个锁住临界区的锁必须由上锁的线程解开,因此 mutex 的功能也就限制在了构造临界区上。

对于信号量来说,任意多线程都可以对信号量执行 PV 操作。

  • 第二个是用途

也就是同步和互斥的用处。

互斥很好说了,当我占有使用权的时候别人不能进入,独占式访问某段程序和内存。

同步就是第一方面是所有权的概念,第二个方面是用途。,即一些线程生产一些线程消费,让生产和消费线程保持合理执行顺序。因为 semaphore 本意是信号灯,含量了通知的意味,只要是我的信号量值大于等于 1,那么就可以有线程或者进程来使用。

『同步』这个词也可以拆开看,一侧是等待数据的『事件』或者『通知』,一侧是保护数据的 『临界区』,所以同步也即调度线程。信号量可以满足这两个功能,但是可以注意到两个功能的应用场景还是蛮大的,有 同步 + 互斥 的空间。linux 内核曾将 semaphore 作为同步原语,后面代码变得较难维护,刷了一把 mutex 变简单了不少还变快了,需要『通知』 的场景则替换为了 completion variable。

  • 举个例子

公司的咖啡机,互斥就是我一个人独占咖啡机,做完了咖啡然后撤走,咖啡机可以被别人使用。

信号量是同步 + 互斥,有一个人不停地生产咖啡,每个人排队拿,如果有了就拿,没有了就阻塞等待。等有咖啡了唤醒阻塞的线程拿咖啡。

所以更像是 mutex+condition_variable

  • 英文的解释

A mutex

is essentially the same thing as a binary semaphore and sometimes uses

the same basic implementation. The differences between them are in how

they are used. While a binary semaphore may be used as a mutex, a mutex

is a more specific use-case, in that only the thread that locked the

mutex is supposed to unlock it. This constraint makes it possible to

implement some additional features in mutexes:

  1. Since only the thread that locked the mutex is supposed to unlock

it, a mutex may store the id of thread that locked it and verify the

same thread unlocks it.

  1. Mutexes may provide priority inversion

safety. If the mutex knows who locked it and is supposed to unlock it,

it is possible to promote the priority of that thread whenever a

higher-priority task starts waiting on the mutex.

  1. Mutexes may also provide deletion safety, where the thread holding the mutex cannot be accidentally deleted.
  1. Alternately, if the thread holding the mutex is deleted (perhaps due

to an unrecoverable error), the mutex can be automatically released.

  1. A mutex may be recursive: a thread is allowed to lock it multiple times without causing a deadlock.

Fork 函数相关知识

fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

一个进程调用 fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

本质在于堆区中每一块被分配出去的内存其生命周期都不一样

fork 调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:

  1. 在父进程中,fork 返回新创建子进程的进程 ID;

  2. 在子进程中,fork 返回 0;

  3. 如果出现错误,fork 返回一个负值;

返回值

fork 时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。

子进程与父进程之间除了代码是共享的之外,堆栈数据和全局数据均是独立的。

包含内容

fork 子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似 mmap 的 private 的方式),如果父子进程一直对这个页面是同一个页面,知道其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。

写时复制

fork 之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行 exec 系统调用,因无意义的复制而造成效率的下降。

。正因为 fork 采用了这种写时复制的机制,所以 fork 出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行 exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。。。,这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度滴。

Fork 复制内部,为什么 Fork 返回 0?

参考链接

fork 宏函数展开后代码

 
int fork(void) \
 
{ \
 
long __res; \
 
__asm__ volatile ("int $0x80" \
 
: "=a" (__res) \
 
: "0" (__NR_fork)); \
 
if (__res >= 0) \
 
return (type) __res; \
 
errno = -__res; \
 
return -1; \
 
}
 

fork 是一个宏,内部关键指令就是 0x80 中断,调用中断就会执行相应的中断服务程序 kernel/sched.c 中的 sched_init 方法中就对 0x80 号中断进行了配置,就是说发生该中断就会调用 system_call 方法。会进入 system_cal 这个函数,然后有一个 sys_cal_table。这个表就是一个系统调用函数表,存的都是函数指针。

 
//sys_call_table位于linux/sys.h
 
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, ... };
 

根据索引找到 sys_fork 这个函数,能找到这个函数就是因为由寄存器 eax 传过来个值 2,__NR_fork = 2,, 索引为 2 的函数指针就是 sys_fork。

 
sys_fork:
 
call find_empty_process
 
testl %eax,%eax # 在eax中返回进程号pid。若返回负数则退出。
 
js 1f
 
push %gs
 
pushl %esi
 
pushl %edi
 
pushl %ebp
 
pushl %eax
 
call copy_process
 
addl $20,%esp # 丢弃这里所有压栈内容。
 
1: ret
 

首先 find_empty_process 找到一个可用的进程 id,这个可用的进程 id 在 %eax 里面,接者调用 copy_process

 
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
 
long ebx,long ecx,long edx,
 
long fs,long es,long ds,
 
long eip,long cs,long eflags,long esp,long ss)
 
{
 
struct task_struct *p;
 
int i;
 
struct file *f;
 
  
 
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。
 
// 然后将新任务结构指针放入任务数组的nr项中。其中nr为任务号,由前面
 
// find_empty_process()返回。接着把当前进程任务结构内容复制到刚申请到
 
// 的内存页面p开始处。
 
p = (struct task_struct *) get_free_page();
 
if (!p)
 
return -EAGAIN;
 
task[nr] = p;
 
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
 
// 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将
 
// 进程的状态置为不可中断等待状态,以防止内核调度其执行。然后设置新进程
 
// 的进程号pid和父进程号father,并初始化进程运行时间片值等于其priority值
 
// 接着复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程
 
// 及其子进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time.
 
p->state = TASK_UNINTERRUPTIBLE;
 
p->pid = last_pid; // 新进程号。也由find_empty_process()得到。
 
p->father = current->pid; // 设置父进程
 
p->counter = p->priority; // 运行时间片值
 
p->signal = 0; // 信号位图置0
 
p->alarm = 0; // 报警定时值(滴答数)
 
p->leader = 0; /* process leadership doesn't inherit */
 
p->utime = p->stime = 0; // 用户态时间和和心态运行时间
 
p->cutime = p->cstime = 0; // 子进程用户态和和心态运行时间
 
p->start_time = jiffies; // 进程开始运行时间(当前时间滴答数)
 
// 再修改任务状态段TSS数据,由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+
 
// (long)p)让esp0正好指向该页顶端。ss0:esp0用作程序在内核态执行时的栈。另外,
 
// 每个任务在GDT表中都有两个段描述符,一个是任务的TSS段描述符,另一个是任务的LDT
 
// 表描述符。下面语句就是把GDT中本任务LDT段描述符和选择符保存在本任务的TSS段中。
 
// 当CPU执行切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。
 
p->tss.back_link = 0;
 
p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针。
 
p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同)
 
p->tss.eip = eip; // 指令代码指针
 
p->tss.eflags = eflags; // 标志寄存器
 
p->tss.eax = 0; // 这是当fork()返回时新进程会返回0的原因所在
 
p->tss.ecx = ecx;
 
p->tss.edx = edx;
 
p->tss.ebx = ebx;
 
p->tss.esp = esp;
 
p->tss.ebp = ebp;
 
p->tss.esi = esi;
 
p->tss.edi = edi;
 
p->[tss.es](http://tss.es/) = es & 0xffff; // 段寄存器仅16位有效
 
p->tss.cs = cs & 0xffff;
 
p->[tss.ss](http://tss.ss/) = ss & 0xffff;
 
p->tss.ds = ds & 0xffff;
 
p->tss.fs = fs & 0xffff;
 
p->[tss.gs](http://tss.gs/) = gs & 0xffff;
 
p->tss.ldt = _LDT(nr); // 任务局部表描述符的选择符(LDT描述符在GDT中)
 
p->tss.trace_bitmap = 0x80000000; // 高16位有效
 
// 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO中
 
// 的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理数学协
 
// 处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理器存在标志MP
 
// 也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一个ESC指令开始执行
 
// 之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存起来。捕获处理句柄会
 
// 保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器的所有状态保存到目的操作数
 
// 指定的内存区域中。
 
if (last_task_used_math == current)
 
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
 
// 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,
 
// 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于
 
// 任务结构的内存页。
 
if (copy_mem(nr,p)) {
 
task[nr] = NULL;
 
free_page((long) p);
 
return -EAGAIN;
 
}
 
// 如果父进程中有文件是打开的,则将对应文件的打开次数增1,因为这里创建的子进程会与父
 
// 进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable引用次数均增1.
 
// 与上面同样的道理,子进程也引用了这些i节点。
 
for (i=0; i<NR_OPEN;i++)
 
if ((f=p->filp[i]))
 
f->f_count++;
 
if (current->pwd)
 
current->pwd->i_count++;
 
if (current->root)
 
current->root->i_count++;
 
if (current->executable)
 
current->executable->i_count++;
 
// 随后GDT表中设置新任务TSS段和LDT段描述符项。这两个段的限长均被设置成104字节。
 
// set_tss_desc()和set_ldt_desc()在system.h中定义。"gdt+(nr<<1)+FIRST_TSS_ENTRY"是
 
// 任务nr的TSS描述符项在全局表中的地址。因为每个任务占用GDT表中2项,因此上式中
 
// 要包括'(nr<<1)'.程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由
 
// CPU自动加载。最后返回新进程号。
 
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
 
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
 
p->state = TASK_RUNNING; /* do this last, just in case */
 
return last_pid;
 
}
 

copy_process 定义在 kernel/fork.c 里面,就是把父进程资源复制到子进程中,设置子进程的内存页等信息,如果父进程调用 fork 结束,就会返回 __res,因为 __res%eax 寄存器是绑定的,寄存器里面存的就是进程的 pid,因为 copy_process 的返回值就是 last_pid,也就是子进程 pid,注意这里是父进程返回,子进程还没执行呢,但是子进程也会返回 __res__res 的值就是寄存器 %eax 的值。 睁大眼睛看代码力里有这么一段 p->tss.eax = 0;,父进程已经把子进程 %eax 寄存器中的值设置为 0 了。明白为什么使用 fork 时,返回 0 就代表是子进程了吧。

通俗的解释,可以这样看待:“其实就相当于链表,进程形成了链表,父进程的 fork 函数返回的值指向子进程的进程 id, 因为子进程没有子进程,所以其 fork 函数返回的值为 0.

执行顺序

参考链接 该连接中说在多核是可以同时执行的,单核的话不确定,但是从 cow 来说确实应该子进程先执行。

复制进程的函数是 copy_process,如果 copy_process 调用成功的话,那么系统会有意让新开辟的进程运行,这是因为子进程一般都会马上调用 exec() 函数来执行其他的任务,这样就可以避免写是复制造成的开销,或者从另一个角度说,如果其首先执行父进程,而父进程在执行的过程中,可能会向地址空间中写入数据,那么这个时候,系统就会为子进程拷贝父进程原来的数据,而当子进程调用的时候,其紧接着执行拉 exec() 操作,那么此时,系统又会为子进程拷贝新的数据,这样的话,相比优先执行子程序,就进行了一次“多余”的拷贝。

操作系统如何执行程序

程序执行流程:从启动到 main() 之前

当您在 Linux 系统上执行一个程序时,在 main() 函数被调用之前,操作系统和运行时环境需要完成一系列复杂的准备工作。以下是详细的执行流程:

1. 用户发起执行请求

# 用户在终端输入命令
./my_program arg1 arg2

2. shell 解析命令

shell 解析命令行,处理:

  • 路径查找(在 PATH 环境变量中搜索)
  • 参数解析
  • 重定向和管道处理
  • 环境变量设置

3. 系统调用 execve()

shell 调用 execve() 系统调用:

execve("./my_program", argv, envp);

4. 内核处理流程

4.1 文件验证和权限检查

// 内核检查:
// 1. 文件是否存在
// 2. 是否有执行权限
// 3. 是否为有效的可执行文件格式
// 4. 用户是否有权限执行

4.2 程序加载(ELF 文件处理)

对于 ELF 格式的可执行文件:

// 读取 ELF 头部
struct elf_header {
    unsigned char e_ident[16];  // ELF 标识
    uint16_t e_type;           // 文件类型
    uint16_t e_machine;        // 目标架构
    uint32_t e_version;        // 版本
    uint64_t e_entry;          // 程序入口点
    uint64_t e_phoff;          // 程序头表偏移
    uint64_t e_shoff;          // 节头表偏移
    uint32_t e_flags;          // 处理器特定标志
    uint16_t e_ehsize;         // ELF 头大小
    uint16_t e_phentsize;      // 程序头表项大小
    uint16_t e_phnum;          // 程序头表项数量
    uint16_t e_shentsize;      // 节头表项大小
    uint16_t e_shnum;          // 节头表项数量
    uint16_t e_shstrndx;       // 节名称字符串表索引
};

4.3 内存布局创建

内核为进程创建虚拟内存布局:

// 典型的进程内存布局
+------------------------+
|        Kernel          |  |
+------------------------+  | 高地址
|        Stack           |  | (向下增长)
|         ...            |  |
+------------------------+  |
|       Stack Guard      |  |
+------------------------+  |
|         Heap           |  | (向上增长)
|         ...            |  |
+------------------------+  |
|      Uninitialized     |  |
|      Data (BSS)        |  |
+------------------------+  |
|      Initialized       |  |
|       Data (DATA)      |  |
+------------------------+  |
|       Text (CODE)      |  |
+------------------------+  ↓ 低地址

4.4 段加载

内核根据程序头表加载各个段:

// 程序头表项结构
struct elf_phdr {
    uint32_t p_type;   // 段类型
    uint32_t p_flags;  // 段标志
    uint64_t p_offset; // 文件偏移
    uint64_t p_vaddr;  // 虚拟地址
    uint64_t p_paddr;  // 物理地址
    uint64_t p_filesz; // 文件大小
    uint64_t p_memsz;  // 内存大小
    uint64_t p_align;  // 对齐
};
 
// 常见段类型:
// PT_LOAD    - 可加载段
// PT_DYNAMIC - 动态链接信息
// PT_INTERP  - 解释器路径
// PT_PHDR    - 程序头表
// PT_TLS     - 线程局部存储模板

5. 动态链接器介入

如果程序是动态链接的,内核会加载动态链接器:

// 典型的解释器路径
/lib64/ld-linux-x86-64.so.2  // x86_64
/lib/ld-linux.so.2           // x86

5.1 动态链接器的工作

// 1. 解析程序依赖
// 2. 加载共享库
// 3. 符号解析和重定位
// 4. 执行共享库的初始化函数
 
// 处理 .dynamic 段
struct {
    uint64_t d_tag;
    union {
        uint64_t d_val;
        uint64_t d_ptr;
    } d_un;
} dynamic[];
 
// 常见的动态条目:
// DT_NEEDED     - 依赖的共享库
// DT_PLTRELSZ   - PLT 重定位表大小
// DT_HASH       - 符号哈希表
// DT_STRTAB     - 字符串表
// DT_SYMTAB     - 符号表
// DT_RELA       - 重定位表
// DT_RELASZ     - 重定位表大小
// DT_INIT       - 初始化函数
// DT_FINI       - 终止函数

5.2 重定位过程

// 重定位表项
struct elf_rela {
    uint64_t r_offset; // 重定位位置
    uint64_t r_info;   // 重定位类型和符号索引
    int64_t r_addend;  // 加数
};
 
// 处理 GOT (Global Offset Table) 和 PLT (Procedure Linkage Table)
// 实现延迟绑定 (Lazy Binding)

6. 运行时初始化

6.1 C 运行时启动代码

控制权转移到 C 运行时的启动代码(通常在 crt1.o, crti.o, crtbegin.o 中):

// 典型的启动序列
_start:
    // 1. 保存命令行参数和环境变量
    mov %rsp, %rbp
    pop %rdi                    // argc
    mov %rsp, %rsi              // argv
    // 跳过 argc 和 argv,找到 envp
    lea 8(%rdi*8)(%rsi), %rdx   // envp
    
    // 2. 设置栈对齐
    and $-16, %rsp
    
    // 3. 调用 _init 函数(如果有)
    call __libc_start_main
    
    // 4. 不应该到达这里
    hlt

6.2 __libc_start_main 函数

这是 glibc 中的关键函数,负责最后的初始化工作:

int __libc_start_main(
    int (*main)(int, char **, char **),  // main 函数指针
    int argc,                           // 参数个数
    char **argv,                        // 参数向量
    __typeof(main) init,               // 初始化函数
    void (*fini)(void),                // 终止函数
    void (*rtld_fini)(void),           // 动态链接器终止函数
    void *stack_end                    // 栈结束地址
) {
    // 1. 初始化 libc 子系统
    __pthread_initialize_minimal();
    __init_misc();
    
    // 2. 设置信号处理
    __sigprocmask(SIG_SETMASK, &initial_mask, NULL);
    
    // 3. 初始化 stdio
    __stdio_init();
    
    // 4. 调用构造函数(C++ 全局对象构造)
    __libc_csu_init();
    
    // 5. 调用 main 函数
    result = main(argc, argv, __environ);
    
    // 6. 调用终止函数
    __libc_csu_fini();
    
    // 7. 调用 exit
    exit(result);
}

6.3 C++ 全局对象构造

对于 C++ 程序,还需要构造全局对象:

// 处理 .init_array 段中的构造函数
void __libc_csu_init() {
    // 调用 preinit_array 中的函数
    for (size_t i = 0; i < preinit_array_cnt; i++) {
        preinit_array[i]();
    }
    
    // 调用 init_array 中的构造函数
    for (size_t i = 0; i < init_array_cnt; i++) {
        init_array[i]();
    }
}

7. 环境准备

7.1 环境变量设置

// 从 execve 的 envp 参数获取环境变量
char *envp[] = {
    "PATH=/usr/bin:/bin",
    "HOME=/home/user",
    "USER=user",
    "SHELL=/bin/bash",
    // ... 其他环境变量
    NULL
};

7.2 文件描述符继承

继承父进程的文件描述符:

  • 标准输入 (0)
  • 标准输出 (1)
  • 标准错误 (2)
  • 其他打开的文件描述符

8. 最终跳转到 main()

完成所有准备工作后,最终调用 main 函数:

// 调用 main 函数
int result = main(argc, argv, envp);
 
// main 函数原型
int main(int argc, char *argv[], char *envp[]);

总结

从执行程序到 main() 函数被调用,整个过程涉及:

  1. 内核层面:文件验证、内存分配、段加载
  2. 动态链接:共享库加载、符号解析、重定位
  3. 运行时初始化:C 运行时设置、全局对象构造
  4. 环境准备:参数传递、环境变量、文件描述符

这个复杂的过程确保了程序能够在正确的环境中运行,所有依赖都已准备就绪,内存布局正确,全局状态已初始化。理解这个过程有助于更好地理解程序的执行机制和调试复杂的问题。