操作系统概述(一、并发)

6 篇文章 0 订阅
2 篇文章 0 订阅

系列文章目录



前言

操作系统广义上讲可以是…非常广

这里只讨论狭义上的操作系统,如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 更易于阅读使用的帮助文档

二、

程序状态模型

数字电路在做的事情:

  1. 设置运行所需初值,初值了来源可以是其它电路(包括自身电路)输出的也可以是什么东西设置的
  2. 运行电路
  3. 为了方便显示,做一些操作可能是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); }

自旋锁的使用场景

  1. 临界区几乎不“拥堵”
  2. 持有自旋锁时禁止执行流切换

使用场景:操作系统内核的并发数据结构(短临界区)

  • 操作系统可以关闭中断和抢占
    • 保证锁的持有者在很短的时间内可以释放
  • (虚拟机。。。)
    • 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);
}
  1. 两个条件变量: 略
  2. 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%

八、并发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

总结

生产者消费者模型还得是信号量(还是要具体问题具体分析)

自旋锁 -> 互斥锁 -> 条件变量 -> 信号量
自旋锁的实现需要依赖原子操作,而原子操作单靠软件是无法实现的,需要硬件提供支持。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值