操作系统
并发、进程、互斥与同步的基础。
操作系统
OS是管理软件的一种软件
C程序与可以看成是一种状态机。因为每一步程序的执行都会是状态的变化,其中状态包括函数的栈帧、变量的值、PC等。
状态机是一个状态的集合,状态之间通过转移,来完成一个任务的过程。通俗的理解:A -> B -> C 就是一个简单的状态机。
系统调用(syscall):系统处理状态转换的一种方式(本质也是一种状态机)。在不同平台实现的方式对应的不同汇编指令不同,存储系统调用参数的寄存器也不同,这是不同架构间的区别。
多发射,乱序执行,按序提交(处理器能看到即将进行的步骤,并且优化)。因此,多处理器丢失即时可见性。(宽松内存模型)。
python的生成器可以看作线程or一个局部的状态机。
并发
线程和进程是并发的基础。线程是CPU的调度基本单位,进程是资源调度的基本单位。在实现上并没有太大的区别,但进程拥有独立的地址空间,线程也有独立的地址空间,多个线程可以共享同一进程的内存空间。
peterson算法
一种互斥的约定算法,保证了原子性。store(设置标志表明要去竞争资源)和load(获取当前是否有人在使用资源,当没有人使用时由速度决定,因此是随机的)
void TA(){
while(1){
x = 1;
turn = B; // sotre
while(y && turn == B); // load
use(); // 进入临界区
x = 0;
}
}
void TB(){
while(1){
y = 1;
turn = A;
while(x && turn == A);
use();
y = 0;
}
}
互斥
自旋锁
通过原子指令,去交换锁。 这是c++实现的一个自旋锁
#include <atomic>
#define LOCK 1
#define UNLOCK 0
// 假设XCHG是一个原子交换函数,这里我们使用std::atomic_exchange来模拟
// 亦可以使用汇编代码模拟
template<typename T>
T XCHG(std::atomic<T>* ptr, T newval) {
return std::atomic_exchange(ptr, newval);
}
std::atomic<int> status(UNLOCK); // 使用std::atomic确保原子性
void lock() {
// 如果置换出的状态时锁可以拿的
while (XCHG(&status, LOCK) == LOCK) {
// 自旋等待,直到获取锁
}
// 注意:这里不需要再次交换status,因为我们已经成功获取了锁
}
void unlock() {
XCHG(&status, LOCK); // 释放锁
}
适合:操作系统内核的并发数据结构(短临界区) 问题: 1、不同CPU的缓存同步。2、空转导致其他CPU在干没有意义的工作。3、拥有锁的线程可能被sleep。
互斥锁
通过系统调用,实现原子的store和load去获取锁。
适合:长临界区 问题:系统调用进入内核,导致过多额外的开销时间
futex
结合自旋锁和互斥锁,可以减少自旋时间。在互斥的过程中,加入一部分的自旋。自旋是一个fast_path,当条件满足时,直接返回,否则就自旋。
同步
条件变量(condition_valiable)
条件变量可以等待一个条件,当条件满足时,唤醒等待的线程。这个过程是依赖锁实现的。在这个过程中会先上锁,在去判定cv,如果不满足就释放锁,然后等待(或者再次去抢锁判断,本质是一个空转的过程,但是会开销CPU)。当条件满足时,唤醒等待的线程,然后重新上锁,然后继续往下执行。 主要的函数有: wait: 等待唤醒 notify/notify_all:唤醒其他线程(这个时候就要注意了,多个线程间的CV,时间上可能存在竞争)
信号量
PV操作,P(等待) V(改变信号量的值),但这个过程是原子的。 在生产者-消费者模型的实例:
void producer(Semaphore& sem) {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟生产时间
sem.V(); // 生产一个资源
std::cout << "Produced resource " << i << std::endl;
}
}
void consumer(Semaphore& sem) {
for (int i = 0; i < 5; ++i) {
sem.P(); // 消费一个资源
std::cout << "Consumed resource " << i << std::endl;
}
}
异步
异步: (promise) 更像是在多个分支的执行流程中,允许主体的流程非阻塞运行,当某个流程结果返回后,在进行后续的处理。这个过程不一定是多线程的,但是具有并发性的。 async/await
死锁与数据竞争
AA:也有可能发生死锁,但很容易解决,给锁加编号。 ABBA:环形死锁。
图这个数据结构表明死锁的概率。图中节点表示锁,边表示进程(或者反过来)。如果存在环,则存在死锁。
数据竞争是多个线程操作共享内存时存在改变共享内存数据的情况(存在一个写数据的操作)。
模拟金丝雀,检测内存溢出或防篡改。
void fun(){
int canary = VALUE;
...
canary ^= VALUE;
asert(canary == VALUE);
}
检测数据竞争。
int data = 1;
// 如果在延迟时间内数据被修改了,证明有其他线程进来改了数据,发生了数据竞争。
int read1 = data;
delay();
int read2 = data;
asert (read1 == read2);
进程
os的启动时,就会创建一个进程(当然在这之前还有很多的工作),在内核编译的时候你可以hook它。
进程相关的系统调用:
fock;创建父子进程,父子进程共享地址空间,但是父子进程的pid是不同的。在状态机的视角,是拷贝当前的状态。这个拷贝的过程,包括PC、缓存等等。父进程中子进程的id是0。
execve(file, argv, env):执行可执行的file且可以传入参数、环境变量。唯一能执行文件的系统调用。替换进程,但是不会释放内存,但是会释放文件描述符。
_exit(int status):退出进程,释放资源,但是不会释放内存, syscall(SYN_EXIT, 0)就是简单的关闭一个,但前者在多线程下是复杂的,前者是gouroupd 。
进程的地址空间
进程的地址空间即使一块连续的内存。内存内部可以划分为多个区域,区域的权限是有status的,区域中存的可以是一些设备和文件。在Linux系统中在/proc/[pid]/maps中会保存有PID对应进程的地址空间情况。可以使用pmap pid查看。men是通过文件的形式打开内存的地址空间。
进程的地址空间:堆、栈、代码、数据、文件、设备等。有些进程也会存放一些无需进入内核的系统调用:vvar/vdso/...。getimeofday。
修改空间中的数据:可以通过监控数据的变化确定,数据映射到的实际内存地址。
hook: 通过修改地址空间,让进程去执行我们自己定义的函数。
管道pipe
操作系统中的对象:文件、字节流等等。
指向操作系统对象的指针(文件描述符/句柄)。
pipe:管道,进程间通信。有写口和读口。fds[0(read)-1(write)]