系列文章目录
文章目录
- 系列文章目录
- 前言
- 定义
- 一、操作系统发展史
- 1940s的程序
- 1950s的计算机
- 1960s的计算机
- 1970s+ 基本和现代一样了
- others
- 二、
- 程序状态模型
- 从不同视角看程序:
- 操作系统上的程序
- 三、线程库
- 四、程序并发
- 五、自旋锁与互斥锁的实现
- 自旋锁的使用场景
- 六、并发控制:同步 (条件变量、信号量、生产者-消费者
- 信号量
- 信号量设计的重点
- 条件变量
- 吃饭问题
- 七、高并发编程
- 八、并发bug, 如何修bug
- 并发bug:死锁(deadlock)
- 总结
前言
操作系统广义上讲可以是…非常广
这里只讨论狭义上的操作系统,如Windows、Linux
定义
操作系统是负责管理软硬件资源,为应用程序和用户提供服务的 系统的 大型 软件。
所以说,操作系统和普通的软件没有本质区别,只不过它会直接操纵硬件资源;当程序员想要申请128byte的内存空间时,只需要调用通过系统提供的API即可,而不是自行编写申请内存空间的程序,所以说操作系统为程序和用户提供服务。
一、操作系统发展史
1940s的程序
此时并不需要操作系统,也不存在这种概念。用户只需要将写好的一沓沓纸带(程序)放入计算器,然后计算器就会自动执行纸带上的指令,输入结果。
就像51单片机,把程序烧录进内存,然后单片机器执行程序中的指令,输出结果,并不需要操作系统。
1950s的计算机
为了管理多个用户的程序,引入操作系统的概念:operator(操作系统)jobs(任务)system系统
此时一个FORTRAN程序就是一沓卡片,卡片上不同的孔位代表不同的指令,多个程序就需要非常多沓的卡片。操作系统要做的就是上一沓卡片执行完后将下一沓卡片“拿”过来执行。为所有程序提供API,如将结果保存到另一沓卡片上。
- “批处理系统”+库函数API
- DOS Disk Operating Systems
- 操作系统中开始出现“设备”、“文件”、“任务”等对象和API
1960s的计算机
集成电路、总线出现
- 更快的处理器
- 更快更大的内存;虚拟存储器出现
- 可以同时载入多个程序而不用“换卡”了
- 更丰富的IO设备;完善的中断/异常机制
内存变大,可以将多个程序都放入内存中。但是,只有一个CPU。通常程序在执行期间并不全程使用CPU,而是存在不使用CPU的空闲期。
能载入多个程序到内存且灵活调度它们的管理程序,包括程序可以调用的API
- 有了进程process的概念
- 进程在执行IO时,可以将CPU让给另一个进程
- 在多个地址空间隔离的程序之间切换
- 虚拟存储使一个程序出bug不会crash真个系统
操作系统中自然增加进程管理的API
基于中断(如时钟)机制
- 时钟中断:使程序在执行时,异步地插入函数调用
- 由操作系统(调度策略)决定是否要切换到另一个程序执行
1970s+ 基本和现代一样了
others
tldr 是比 man 更易于阅读使用的帮助文档
二、
程序状态模型
数字电路在做的事情:
- 设置运行所需初值,初值了来源可以是其它电路(包括自身电路)输出的也可以是什么东西设置的
- 运行电路
- 为了方便显示,做一些操作可能是printf也可能是其它的
1.2.3步在时钟周期的控制下重复进行,可能是每来一个周期信号就进行一步
从不同视角看程序:
程序是状态机:
每执行一条指令,堆栈的状态就会改变,所以的程序指令在指示堆、栈以及各种指针的状态该如何变化。
程序是二进制的指令
gdb可以从两个视角,C or 汇编 的角度调试程序
程序调用syscall,将程序当前的状态移交给操作系统。
操作系统上的程序
程序 = 状态机 = 计算 --> syscall --> 计算 --> …
用户程序只能做一些计算操作,通过系统调用来完成硬件访问等操作。
操作系统负责管理所有的硬件软件资源
- 只能用操作系统允许的方式访问操作系统中的对象
- 这是为了“管理多个状态机”所必须的
gdb
strace
三、线程库
并发的基本单位:线程
- 执行流拥有独立的堆栈/寄存器
- 共享全部的内存(指针可以相互引用)
在终端的输出是乱序的可以用 | sort 试试
原子性的丧失
顺序
宽松内存模型
编译器有编译优化,x86处理器自身也有优化
四、程序并发
五、自旋锁与互斥锁的实现
互斥算法:Dekker/Peterson
实现互斥的根本困难:不能同时读和写共享内存
- load(环顾四周)的时候不能写,只能“看一眼就把眼睛闭上”
- 看到的东西马上就过时了
- store(改变物理世界状态)的时候不能读,只能“闭着眼睛动手”
- 也不知道把什么改成了什么
假设硬件能为我们提供一条“瞬间完成”的读+写指令
#include </stdatomic.h>提供了一些原子操作,可以利用xchg实现自旋锁。
所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
原子性不可能由软件单独保证–必须需要硬件的支持,因此是和架构相关的。在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。
总结:某条原子指令一旦被执行,必须有且仅有一个线程完整地执行它
int tables = YES;
void lock(){
retry:
int got = xchg(&table, NOPE);
if (got == NOPE)
goto retry;
assert(got == YES)
}
void unlock(){
xchg(&table, YES);
}
int locked = 0;
void lock(){ while(xchg(&locked, 1));}
void unlock() { xchg(&locked, 0); }
自旋锁的使用场景
- 临界区几乎不“拥堵”
- 持有自旋锁时禁止执行流切换
使用场景:操作系统内核的并发数据结构(短临界区)
- 操作系统可以关闭中断和抢占
- 保证锁的持有者在很短的时间内可以释放
- (虚拟机。。。)
- PAUSE指令会触发VM Exit
六、并发控制:同步 (条件变量、信号量、生产者-消费者
信号量
#include <iostream>
#include <thread>
#include <thread>
#include <semaphore.h>
#include <unistd.h>
sem_t sem_a;
sem_t sem_b;
sem_t sem_c;
int a = 1;
int b = 2;
int c = 3;
class NumCnt
{
private:
/* data */
public:
static void fun1()
{
goto entry;
while (1)
{
sem_wait(&sem_c);
entry:
std::cout << a << "thread id : " << std::this_thread::get_id() << std::endl;
a += 3;
sem_post(&sem_a);
usleep(1000*1000);
}
}
static void fun2()
{
while (1)
{
sem_wait(&sem_a);
std::cout << b<< "thread id : " << std::this_thread::get_id() << std::endl;
b += 3;
sem_post(&sem_b);
usleep(1000*1000);
}
}
static void fun3()
{
while (1)
{
sem_wait(&sem_b);
std::cout << c<< "thread id : " << std::this_thread::get_id() << std::endl;
c += 3;
sem_post(&sem_c);
usleep(1000*1000);
}
}
};
int main()
{
sem_init(&sem_a, 0, 0);
sem_init(&sem_b, 0, 0);
sem_init(&sem_c, 0, 0);
std::thread th1(NumCnt::fun1);
std::thread th2(NumCnt::fun2);
std::thread th3(NumCnt::fun3);
th1.join();
th2.join();
th3.join();
}
打印括号问题,要求打印的括号必须匹配:(正确的)
void Productor()
{
while (1)
{
sem_wait(&sem_m); // 左括号最大允许数量
std::cout << "(";
sem_post(&sem_par); // 生产出了多少个
}
}
void Customer()
{
while (1)
{
sem_wait(&sem_par); // 好生产出了,开始消费
std::cout << ")";
sem_post(&sem_m); // 消费了一个,最大生产5个,消费了一个那么还有一个坑位让生产者生产
}
}
信号量设计的重点
- 考虑每一单位的资源是什么?谁创造?谁获取?
- 在“单一位资源”明确的问题上更好用
条件变量
- 将自旋转变为睡眠,在完成操作时唤醒
条件变量:
- wait(conditional_variable, mutex)
- 调用时必须保证已经获得mutex
- 释放mutex、进入睡眠状态
- signal/nofity(conditional_variable)
- 如果有线程在等待cv,则唤醒其中一个线程
- broadcast/nofityAll(cv)
- 唤醒全部正在等待cv的线程
下面的代码在多生产者多消费者时会出bug,需要两个条件变量
有BUG的:
int n, cnt;
条件变量cv, 锁lck
void Tproduce()
{
mutex_lock(&lck);
if (cnt == n) cond_wait(&cv, &lck);
printf("(");
cnt++;
cond_signal(&cv);
mutex_unlock(&lck);
}
void Tconsume()
{
mutex_lock(&lck);
if (cnt == 0) cond_wait(&cv, &lck);
printf(")");
cnt--;
condi_signal(&cv);
mutex_unlock(*lck);
}
- 两个条件变量: 略
- while循环: 如下代码
mutex_t lk = MUTEX_INIT(); cond_t cv = COND_INIT(); void Tproduce() { while(1){ mutex_lock(&lk); while(!(count != n)){ cond_wait(&cv, &lk); } // lock is held printf("("); count++; cond_broadcast(&cv); mutex_unlock(&lk); } } void Tconsume() { while(1){ mutex_lock(&lk); while(!(count != 0)){ cond_wait(&cv, &lk); } printf(")"); count--; cond_broadcast(&cv); mutex_unlock(&lk); } }
- example:
struct job{ void (*run)(void *arg); void *arg; }; while(1){ struct job* job; mutex_lock(&lk); while(job != get_job()){ wait(&cv, &lk); } mutex_unlock(&lk); job->run(job->arg); // 不需要持有锁 // 可以生成新的job // 注意回收分配的资源 }
吃饭问题
- 分布式系统中非常常见的解决思路(master-slave)
// slave 线程,可以有多个
void Tphilosopher(int id)
{
send_request(id, EAT);
P(allowed[id]); // 等待是否继续往下执行
程序处理
send_request(id, DONE);
}
// master 进程,只有一个
void Twaiter()
{
while(1){
id, status = receive_request();
if (status == EAT) {...}
if (status ++DONE) {...}
}
}
设计原则:easy to use, simple to think
- 不要做任何优化,先写出来
- 实现同步的方法
- 条件变量、信号量;生产者-消费之模型
- job queue可以实现几乎任何并行算法
七、高并发编程
高性能计算的主要挑战:
- 计算图需要容易并行化
- 线程间如何通信
数据中心主要挑战:
- 数据要保持一致(Consistency)
- 服务器时刻保持可用(Availability)
- 容忍机器离线(Partition tolerance)
工具:
-
线程
- 线程切换简单记为:1.保存当前状态机的所有寄存器 2. 读取下一个线程的状态
-
协程
- 多个可以保存/恢复的执行流
- 比线程更轻量(完全没有系统调用,也就没有操作系统状态)
#include <stdio.h> int count = 1; void entry(void *arg){ for (int i= 0; i < 5; i++){ printf("%s[&d] ", (const char*)arg, count++);; co_yield(); // 切换协程 } } int main(){ struct co *co1 = co_start("co1", entry, "a"); struct co *co2 = co_start("co2", entry, "b"); co_wait(co1); co_wait(co2); }
Goroutine:概念上是线程,实际是线程和写成的混合体
- 每个CPU上有一个Go Worker,自由调度goroutines
- 执行到blocking API时(如sleep,read)
- Go Worker偷偷改成non-blocking的版本
- 成功 -> 立即继续执行
- 失败 -> 立即yield到另一个需要CPU的goroutine
- 十分巧妙,CPU和操作系统全部用到100%
- Go Worker偷偷改成non-blocking的版本
八、并发bug, 如何修bug
根本原因:编程语言的缺陷
软件是需求(约规)在计算机数字世界的投影
计算机是负责“翻译”代码,不管和实际需求(约规)是否匹配
防御性编程:
把程序需要满足的条件用 assert 表达出来(打个log)
eg:
assert y.left == x; y.right == c; x.left == a; x.right == b;
当写出如下代码:
int* p = (void*)0x23ae3;
*p = 1;
如果是非内核程序,操作系统会帮助用户检查p的值是否合法;如果是操作系统内核,则会默默执行此操作,从而不知在何时引发严重问题。
并发bug:死锁(deadlock)
所有线程都在相互等待。
死锁产生的四个必要条件(Edward G. Coffman, 1971):四个条件缺一不可
- 互斥:
- 请求与保持:
- 不剥夺:
- 循环等待:
并发控制工具:
- 互斥锁 - 原子性
- 条件变量 - 同步
gcc -fsanitize=address
动态程序分析:
- 在事件发生时记录
- 解析记录检查问题
- 付出代价和权衡
动态程序分析工具:
- AddressSanitizers: 非法内存访问
- ThreadSanitizer:数据竞争
- MemorySanitizer:未初始化的读取
- UBSanitizer:undefined behavior
奇怪的知识
msvc中debug mode 的 guard/fence/canary
- 未初始化栈:0xcccccc
- 未初始化堆:0xcdcdcd
- 对象头尾:-0xfdfdfdfd
- 已回收内存:0xddddd
总结
生产者消费者模型还得是信号量(还是要具体问题具体分析)
自旋锁 -> 互斥锁 -> 条件变量 -> 信号量
自旋锁的实现需要依赖原子操作,而原子操作单靠软件是无法实现的,需要硬件提供支持。