rCore_Lab8

本章任务

实现线程 + 并发处理

达科塔盗龙

达科塔盗龙是一种生存于距今6700万-6500万年前白垩纪晚期的兽脚类驰龙科恐龙,它主打的并不是霸王龙的力量路线,而是利用自己修长的后肢来提高敏捷度和奔跑速度。它全身几乎都长满了羽毛,可能会滑翔或者其他接近飞行行为的行动模式。

img

进程、线程和协程中的控制流抽象--任务(Task)

线程与进程

进程强调隔离,线程强调共享

  • 进程间相互独立(即资源隔离),同一进程的各线程间共享进程的资源(即资源共享);
  • 子进程和父进程有不同的地址空间和资源,而多个线程(没有父子关系)则共享同一所属进程的地址空间和资源
  • 每个线程有其自己的执行上下文(线程ID、程序计数器、寄存器集合和执行栈),而进程的执行上下文包括其管理的所有线程的执行上下文和地址空间(故同一进程下的线程间上下文切换比进程间上下文切换要快);
  • 线程是一个可调度/分派/执行的实体(线程有就绪、阻塞和运行三种基本执行状态),进程不是可调度/分派/执行的的实体,而是线程的资源容器;
  • 进程间通信需要通过IPC机制(如管道等), 属于同一进程的线程间可以共享“即直接读写”进程的数据,但需要同步互斥机制的辅助,避免出现数据不一致性以及不确定计算结果的问题。

主线程:

创建进程(比如通过 fork 系统调用创建进程)时建立的第一个线程,我们称之为主线程。类似于进程标识符(PID),每个线程都有一个在所属进程内生效的线程标识符(TID),同进程下的两个线程有着不同的 TID ,可以用来区分它们。主线程由于最先被创建,它的 TID 固定为 0

对于刚创建的线程,除了主线程仍然从程序入口点(一般是 main 函数)开始执行之外,每个线程的生命周期都与程序中的一个函数的一次执行绑定

也就是说,线程从该函数入口点开始执行,当函数返回之后,线程也随之退出。因此,在创建线程的时候我们需要提供程序中的一个函数让线程来执行这个函数。

c语言线程接口pthread系列的pthread_create函数:

#include <pthread.h>

int pthread_create(pthread_t *restrict thread,
                  const pthread_attr_t *restrict attr,
                  void *(*start_routine)(void *),
                  void *restrict arg);
  • 第一个参数为一个类型为 pthread_t 的线程结构体的指针。在实际创建线程之前我们首先要创建并初始化一个 pthread_t 的实例,它与线程一一对应,线程相关的操作都要通过它来进行。
  • 通过第二个参数我们可以对要创建的线程进行一些配置,比如内核应该分配给这个线程多少栈空间。简单起见我们这里不展开。
  • 第三个参数为一个函数指针,表示创建的线程要执行哪个函数
  • 第四个参数为传给线程执行的函数的参数,类型为 void * ,和函数签名中的约定一致。需要这个参数的原因是:方便期间,我们常常会让很多线程执行同一个函数,但可以传给它们不同的参数,以这种手段来对它们进行区分。

线程的创建

内核会为每个线程分配一组专属于该线程的资源:用户栈、Trap 上下文还有内核栈,前面两个在进程地址空间中,内核栈在内核地址空间中。这样这些线程才能相对独立地被调度和执行。相比于创建进程的 fork 系统调用,创建线程无需建立新的地址空间,这是二者之间最大的不同。

内核在收到线程发出的 exit 系统调用后,会回收线程占用的用户态资源,包括用户栈和 Trap 上下文等。线程占用的内核态资源(包括内核栈等)则需要在进程内使用 waittid 系统调用来回收,这样该线程占用的资源才能被完全回收

如果进程/主线程先调用了 exit 系统调用来退出,那么整个进程(包括所属的所有线程)都会退出,而对应父进程会通过 waitpid 回收子进程剩余还没被回收的资源

并发的一些术语

  • 共享资源(shared resource):不同的线程/进程都能访问的变量或数据结构。
  • 临界区(critical section):访问共享资源的一段代码
  • 竞态条件(race condition):多个线程/进程都进入临界区时,都试图更新共享的数据结构,导致产生了不期望的结果
  • 不确定性(indeterminate): 多个线程/进程在执行过程中出现了竞态条件,导致执行结果取决于哪些线程在何时运行,即执行结果不确定,而开发者期望得到的是确定的结果。
  • 原子性(atomic):一系列操作要么全部完成,要么一个都没执行,不会看到中间状态。在数据库领域,具有原子性的一系列操作称为事务(transaction)。
  • 互斥(mutual exclusion):一种原子性操作,能保证同一时间只有一个线程进入临界区,从而避免出现竞态条件,并产生确定的预期执行结果。
  • 同步(synchronization):多个并发执行的进程/线程在一些关键点上需要互相等待,这种相互制约的等待称为进程/线程同步。
  • 死锁(dead lock):一个线程/进程集合里面的每个线程/进程都在等待只能由这个集合中的其他一个线程/进程(包括他自身)才能引发的事件,这种情况就是死锁。
  • 饥饿(hungry):指一个可运行的线程/进程尽管能继续执行,但由于操作系统的调度而被无限期地忽视,导致不能执行的情况。

lab8中线程的设置

image-20240124164142068

对于栈空间,是从ELF分配位置往上加

对于trapContext则是从跳板下往下加

// os/src/config.rs

pub const TRAMPOLINE: usize = usize::MAX - PAGE_SIZE + 1;
pub const TRAP_CONTEXT_BASE: usize = TRAMPOLINE - PAGE_SIZE;

// os/src/task/id.rs

fn trap_cx_bottom_from_tid(tid: usize) -> usize {
    TRAP_CONTEXT_BASE - tid * PAGE_SIZE
}

fn ustack_bottom_from_tid(ustack_base: usize, tid: usize) -> usize {
    ustack_base + tid * (PAGE_SIZE + USER_STACK_SIZE)
}

慈母龙

慈母龙操作系统 -- SyncMutexOS总体结构

锁🔒

例子

四个线程完成从0加到40000,不采用互斥的话,最后结果总不尽人意

static mut A: usize = 0;
const THREAD_COUNT: usize = 4;
const PER_THREAD: usize = 10000;
fn main() {
    let mut v = Vec::new();
    for _ in 0..THREAD_COUNT {
        v.push(std::thread::spawn(|| {
            unsafe {
                for _ in 0..PER_THREAD {
                    A = A + 1;
                }
            }
        }));
    }
    for handle in v {
        handle.join().unwrap();
    }
    println!("{}", unsafe { A });
}

我们可以对A这个共享资源上锁:

use std::sync::Mutex;
const THREAD_COUNT: usize = 4;
const PER_THREAD: usize = 10000;
static A: Mutex<usize> = Mutex::new(0);
fn main() {
    let mut v = Vec::new();
    for _ in 0..THREAD_COUNT {
        v.push(std::thread::spawn(|| {
            for _ in 0..PER_THREAD {
                let mut a_guard = A.lock().unwrap();
                *a_guard = *a_guard + 1;
            }
        }));
    }
    for handle in v {
        handle.join().unwrap();
    }
    println!("{}", *A.lock().unwrap());
}

或者:

use std::sync::Mutex;
static mut A: usize = 0;
static LOCK: Mutex<bool> = Mutex::new(true);
const THREAD_COUNT: usize = 4;
const PER_THREAD: usize = 10000;
fn main() {
    let mut v = Vec::new();
    for _ in 0..THREAD_COUNT {
        v.push(std::thread::spawn(|| {
            for _ in 0..PER_THREAD {
                let _lock = LOCK.lock();
                unsafe { A = A + 1; }
            }
        }));
    }
    for handle in v {
        handle.join().unwrap();
    }
    println!("{}", unsafe { A });
}

评价锁的指标

  • 忙则等待:意思是当一个线程持有了共享资源的锁,此时资源处于繁忙状态,这个时候其他线程必须等待拿着锁的线程将锁释放后才有进入临界区的机会。这其实就是互斥访问的另一种说法。这种互斥性是锁实现中最重要的也是必须做到的目标,不然共享资源访问的正确性会受到影响。
  • 空闲则入 (在《操作系统概念》一书中也被称为 前进 Progress):若资源处于空闲状态且有若干线程尝试进入临界区,那么一定能够在有限时间内从这些线程中选出一个进入临界区。如果不满足空闲则入的话,可能导致即使资源空闲也没有线程能够进入临界区,对于锁来说是不可接受的。
  • 有界等待 (Bounded Waiting):当线程获取锁失败的时候首先需要等待锁被释放,但这并不意味着此后它能够立即抢到被释放的锁,因为此时可能还有其他的线程也处于等待状态。于是它可能需要等待一轮、二轮、多轮才能拿到锁,甚至在极端情况下永远拿不到锁。 有界等待 要求每个线程在等待有限长时间后最终总能够拿到锁。相对的,线程可能永远无法拿到锁的情况被称之为 饥饿 (Starvation) 。这体现了锁实现分配共享资源给线程的 公平性 (Fairness) 。
  • 让权等待(可选):线程如何进行等待实际上也大有学问。这里所说的让权等待是指需要等待的线程暂时主动或被动交出 CPU 使用权来让 CPU 做一些有意义的事情,这通常需要操作系统的支持。这样可以提升系统的总体效率。

忙则等待、空闲则入和有界等待是一个合格的锁实现必须满足的要求,而让权等待则关系到锁机制的效率,是可选的。

自旋锁

单标记组合
    #![no_std]
    #![no_main]

    #[macro_use]
    extern crate user_lib;
    extern crate alloc;

    use alloc::vec::Vec;
    use core::ptr::addr_of_mut;
    use user_lib::{exit, get_time, thread_create, waittid};

    static mut A: usize = 0;
    static mut OCCUPIED: bool = false;
    const PER_THREAD_DEFAULT: usize = 10000;
    const THREAD_COUNT_DEFAULT: usize = 16;
    static mut PER_THREAD: usize = 0;

    unsafe fn critical_section(t: &mut usize) {
        let a = addr_of_mut!(A);
        let cur = a.read_volatile();
        for _ in 0..500 {
            *t = (*t) * (*t) % 10007;
        }
        a.write_volatile(cur + 1);
    }

    unsafe fn lock() {
        while vload!(OCCUPIED) {}           // <==> while OCCUPIED,只不过使用read函数来增加了时间
        OCCUPIED = true;
    }

    unsafe fn unlock() {
        OCCUPIED = false;
    }

    unsafe fn f() -> ! {
        let mut t = 2usize;
        for _ in 0..PER_THREAD {
            lock();
            critical_section(&mut t);
            unlock();
        }
        exit(t as i32)
    }

    #[no_mangle]
    pub fn main(argc: usize, argv: &[&str]) -> i32 {
        let mut thread_count = THREAD_COUNT_DEFAULT;
        let mut per_thread = PER_THREAD_DEFAULT;
        if argc >= 2 {
            thread_count = argv[1].parse().unwrap();
            if argc >= 3 {
                per_thread = argv[2].parse().unwrap();
            }
        }
        unsafe {
            PER_THREAD = per_thread;
        }
        let start = get_time();
        let mut v = Vec::new();
        for _ in 0..thread_count {
            v.push(thread_create(f as usize, 0) as usize);
        }
        for tid in v.into_iter() {
            waittid(tid);
        }
        println!("time cost is {}ms", get_time() - start);
        assert_eq!(unsafe { A }, unsafe { PER_THREAD } * thread_count);
        0
    }

while 循环那里直到标记被改为 false ,在循环体内则不做任何事情,这是一种典型的 忙等待 (Busy Waiting) 策略,它也被形象地称为 自旋 (Spinning) 。我们目前基于单核 CPU ,如果循环第一次迭代发现标记为 true 的话,在触发时钟中断切换到其他线程之前,无论多少次查看标记都必定为 true ,因为当前线程不会修改标记。这就会造成 CPU 资源的严重浪费。

针对这种场景, Rust 提供了 spin_loop_hint 函数,我们可以在循环体内调用该函数来通知 CPU 当前线程正处于忙等待状态,于是 CPU 可能会进行一些优化(比如降频减少功耗等),其在不同平台上有不同表现。此外,如果我们有操作系统支持的话,便可以考虑锁实现评价指标中的“让权等待”,这个我们后面还会讲到。

它能够保证最关键的互斥访问吗?

答案是否定的,他并不能保证互斥

举个例子:

image-20240124221207126

两个进程,P1与P2,P1执行完第28行代码还未执行第29行代码时发生了切换,此时OCCUPIED还是false,P2也能执行完28行,并执行临界区

问题的本质是:在这个实现中,标记 OCCUPIED 也成为了多线程均可访问的 共享资源 ,那么 它也需求互斥访问 。而我们并没有吸取 adder.rs 的教训,我们让操作为多阶段多指令的 OCCUPIED 无任何保护的暴露在操作系统调度面前,那么自然也会发生和 adder.rs 类似的问题。那么应该如何解决问题呢?参照 adder_fixed.rs ,在硬件的支持下,将对标记的操作替换为原子操作显然很靠谱。但我们不禁要问,如果不依赖硬件,是否有一种纯软件的解决方案呢?其实在某些限制条件下是可以的:

多标记组合

Peterson 算法

//! It only works on a single CPU!

#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;
extern crate alloc;

use alloc::vec::Vec;
use core::ptr::addr_of_mut;
use core::sync::atomic::{compiler_fence, Ordering};
use user_lib::{exit, get_time, thread_create, waittid};

static mut A: usize = 0;
static mut FLAG: [bool; 2] = [false; 2];
static mut TURN: usize = 0;
const PER_THREAD_DEFAULT: usize = 2000;
const THREAD_COUNT_DEFAULT: usize = 2;
static mut PER_THREAD: usize = 0;

unsafe fn critical_section(t: &mut usize) {
    let a = addr_of_mut!(A);
    let cur = a.read_volatile();
    for _ in 0..500 {
        *t = (*t) * (*t) % 10007;
    }
    a.write_volatile(cur + 1);
}

unsafe fn lock(id: usize) {
    FLAG[id] = true;
    let j = 1 - id;
    TURN = j;
    // Tell the compiler not to reorder memory operations
    // across this fence.
    compiler_fence(Ordering::SeqCst);
    // Why do we need to use volatile_read here?
    // Otherwise the compiler will assume that they will never
    // be changed on this thread. Thus, they will be accessed
    // only once!
    while vload!(FLAG[j]) && vload!(TURN) == j {}
}

unsafe fn unlock(id: usize) {
    FLAG[id] = false;
}

unsafe fn f(id: usize) -> ! {
    let mut t = 2usize;
    for _iter in 0..PER_THREAD {
        lock(id);
        critical_section(&mut t);
        unlock(id);
    }
    exit(t as i32)
}

#[no_mangle]
pub fn main(argc: usize, argv: &[&str]) -> i32 {
    let mut thread_count = THREAD_COUNT_DEFAULT;
    let mut per_thread = PER_THREAD_DEFAULT;
    if argc >= 2 {
        thread_count = argv[1].parse().unwrap();
        if argc >= 3 {
            per_thread = argv[2].parse().unwrap();
        }
    }
    unsafe {
        PER_THREAD = per_thread;
    }

    // uncomment this if you want to check the assembly
    // println!(
    //     "addr: lock={:#x}, unlock={:#x}",
    //     lock as usize,
    //     unlock as usize
    // );
    let start = get_time();
    let mut v = Vec::new();
    assert_eq!(
        thread_count, 2,
        "Peterson works when there are only 2 threads."
    );
    for id in 0..thread_count {
        v.push(thread_create(f as usize, id) as usize);
    }
    let mut time_cost = Vec::new();
    for tid in v.iter() {
        time_cost.push(waittid(*tid));
    }
    println!("time cost is {}ms", get_time() - start);
    assert_eq!(unsafe { A }, unsafe { PER_THREAD } * thread_count);
    0
}

image-20240124221602856

关中断法实现互斥

这种方法的优点是简单,但是缺点则很多。首先,这种做法给了用户态程序使能/屏蔽中断这种特权,相当于相信应用并放权给它。这会面临和我们引入抢占式调度之前一样的问题:线程可以选择恶意永久关闭中断而独占所有 CPU 资源,这将会影响到整个系统的正常运行。

因此,事实上至少在 RISC-V 这样含多个特权级的架构中,这甚至是完全做不到的

回顾第三章,可以看到中断使能和屏蔽的相关标志位分布在 S 特权级的 CSR sstatus 和 sie ,甚至更高的 M 特权级的 CSR 中。用户态试图修改它们将会触发非法指令异常,操作系统会直接杀死该线程。

其次,即使我们能够做到,它对于多处理器架构也是无效的。假设同一进程的多个线程运行在不同的 CPU 上,它们都尝试访问同种共享资源。一般来说,每个 CPU 都有自己的独立的中断相关的寄存器,它只能对自己的中断处理进行设置。对于一个线程来说,它可以关闭它所在 CPU 的中断,但是这无法影响到其他线程所在的 CPU ,其他线程仍然可以在此时进入临界区,便不能满足互斥访问的要求了。

所以,采用控制中断的方式仅对一些非常简单,且信任应用的单处理器场景有效,而对于更广泛的其他场景是不够的。

原子指令

CAS指令

比较并交换 (Compare-And-Swap, CAS)

它在不同平台上的具体表现存在细微差异,我们这里按照 RISC-V 的写法描述它的核心功能:有三个源寄存器和一个目标寄存器,于是应该写成 CAS rd, rs1, rs2, rs3 。它

的功能是将一个内存位置存放的值(其地址保存在一个源寄存器中,假设是 rs1 )与一个期待值 expected (保存在源寄存器 rs2 中)进行比较,如果相同的话就将内存位置存放的值改为 new (保存在源寄存器 rs3 中)。无论是否相同,都将执行 CAS 指令之前这个内存位置存放的值写入到目标寄存器 rd 中。

用rust描述:

fn compare_and_swap(ptr: *mut i32, expected: i32, new: i32) -> i32 {
    let original = unsafe { *ptr };
    if original == expected {
        unsafe { *ptr = new; }
    }
    original
}

image-20240124225142106

TAS指令

测试并设置 (Test-And-Set, TAS)

相比 CAS , TAS 没有比较的步骤,它直接将 new 写入到内存并返回内存位置原先的值。用 Rust 语言伪代码描述 TAS 的功能如下:

fn test_and_set(ptr: *mut i32, new: i32) -> i32 {
    let original = unsafe { *ptr };
    unsafe { *ptr = new };
    original
}

image-20240124225119664

RISC-V 架构上的原子指令

我们曾经在 第二章介绍 Trap 上下文保存与恢复 的时候用到过一系列读写 RISC-V 控制状态寄存器(CSR)的特殊指令,比如 csrr , csrw ,特别是 csrrw 等。当时我们提到这些指令也都是原子指令。

除此之外, RISC-V 架构的原子拓展(Atomic,简称 A 拓展)提供了一些对于一个内存位置上的值进行原子操作的原子指令,分为两大类。

其中第一类被称为原子内存操作(Atomic Memory Operation, AMO)。这类原子指令首先根据寄存器 rs1 保存的内存地址将值从内存载入到寄存器 rd 中,然后将这个载入的值与寄存器 rs2 中保存的值进行某种运算,并将结果写回到 rs1 中的地址对应的内存区域中。整个过程可以被概括为一种 read-modify-write 的三阶段操作,硬件能够保证其原子性。AMO 支持多种不同的运算,包括交换、整数加法、按位与、按位或、按位异或以及有/无符号整数最大或最小值。容易看出,这类指令能够方便地实现 adder_fixed.rs 中 Rust 提供的 fetch_add 这类原子操作。

RISC-V 提供的另一类原子指令被称为加载保留/条件存储(Load Reserved / Store Conditional,简称LR/SC),它们通常被配对使用。首先, LR 指令可以读取内存中的一个值(其地址保存在寄存器 rs1 中)到目标寄存器 rd 。然后,可以使用 SC 指令,它的功能是将内存中的这个值(其地址保存在寄存器 rs1 中且与 LR 指令中的相同)改成寄存器 rs2 保存的值,但前提是:执行 LR 和 SC 这两条指令之间的这段时间内,内存中的这个值并未被修改。如果这个前提条件不满足,那么 SC 指令不会进行修改。SC 指令的目标寄存器 rd 指出 SC 指令是否进行了修改:如果进行了修改, rd 为 0;否则, rd 可能为一个非零的任意值。

那么 SC 指令是如何判断此前一段时间该内存中的值是否被修改呢?在 RISC-V 架构下,存在一个 保留集 (Reservation Set) 的概念,这也是“加载保留”这种叫法的来源。保留集用来实现 LR/SC 的检查机制:当 CPU 执行 LR 指令的时候,硬件会记录下此时内存中的值是多少,此外还可能有一些附加信息,这些被记录下来的信息就被称为保留集。之后,当其他 CPU 或者外设对内存这个值进行修改的时候,硬件可以将这个值对应的保留集标记为非法或者删除。等到之前执行 LR 指令的 CPU 执行 SC 指令的时候,CPU 就可以检查保留集是否存在/合法或者保留集记录的值是否与内存中现在的值一致,以这种方式来决定是否进行写入以及目标寄存器 rd 的值。

RISC-V 并不原生支持 CAS/TAS 原子指令,但我们可以通过 LR/SC 指令对来实现它。

# 参数 a0 存放内存中的值的所在地址
# 参数 a1 存放 expected
# 参数 a2 存放 new
# 返回值 a0 略有不同:这里若比较结果相同则返回 0 ,否则返回 1
# 而不是返回 CAS 之前内存中的值
cas:
    lr.w t0, (a0) # LR 将值加载到 t0
    bne t0, a1, fail # 如果值和 a1 中的 expected 不同,跳转到 fail
    sc.w t0, a2, (a0) # SC 尝试将值修改为 a2 中的 new
    bnez t0, cas # 如果 SC 的目标寄存器 t0 不为 0 ,说明 LR/SC 中间值被修改,重试
    li a0, 0 # 成功,返回值为 0
    ret # 返回
fail:
    li a0, 1 # 失败,返回值为 1
    ret # 返回

小结

重新回顾一下这些指令,可以发现它们从结果上都存在成功/失败之分。如果多个 CPU 同时用这些指令访问内存中同一个值,显然只有一个 CPU 能够成功

事实上,当多个 CPU 同时执行这些原子指令的时候,它们会将相关请求发送到 CPU 与 RAM 间总线上,总线会将这些请求进行排序。这就好像一群纷乱的游客在通过一个狭窄的隘口的时候必须单列排队通过,无论如何总会产生一种顺序。

于是我们会看到,请求排在最前面的 CPU 能够成功,随后它便相当于独占了这一块被访问的内存区域。接下来,排在后面的 CPU 的请求都会失败了,这种状况会持续到之前独占的 CPU 将对应内存区域重置(相当于 unlock )。

正因如此,我们才说:“ 原子指令是整个计算机系统中最根本的原子性和互斥性的来源 。”这种最根本的互斥性来源于总线的仲裁,表现为原子指令,作用范围为基础存储单位。在原子指令的基础上,我们可以灵活地编写软件来延伸互斥性或其他同步需求的作用范围,使得对于各种丰富多彩的资源(如复杂数据结构和多种外设)我们都能将其管理得有条不紊。

虽然原子指令已经能够简单高效的解决问题了,但是在很多情况下,我们可以在此基础上再引入软件对资源进行灵活的调度管理,从而避免资源浪费并得到更高的性能。

让权等待

Peterson虽然结果是正确的,但while循环一直忙等,效率低

yield让权

我们可以采用yield暂时让权,线程可以在发现条件暂时不成立的情况下通过 yield 系统调用主动交出 CPU 使用权

用 yield 实现通常情况下不会出错。但是它的实际表现却很大程度上受到操作系统调度器的影响。

如果在条件满足之前就多次调度到等待的线程,虽然看起来线程很快就会再次通过 yield 主动让权从而没什么开销,但是实际上却增加了上下文切换的次数。上下文切换的开销是很大的,除了要保存和恢复寄存器之外,更重要的一点是会破坏程序的时间和空间局部性使得我们无法高效利用 CPU 上的各类缓存。

比如说,我们的实现中在 Trap 的时候需要切换地址空间,有可能需要清空 TLB ;由于用户态和内核态使用不同的栈,在应用 Trap 到内核态的时候,缓存中原本保存着用户栈的内容,在执行内核态代码的时候可能由于缓存容量不足而需要逐步替换成内核栈的内容,而在返回用户态之后又需要逐步替换回来。整个过程中的缓存命中率将会很低。所以说,即使线程只是短暂停留也有可能对整体性能产生影响。

相反,如果在条件满足很久之后才调度到等待的线程,这则会造成事件的响应延迟不可接受。使用 yield 就有可能出现这些极端情况,而且我们完全无法控制或预测其效果究竟如何。因此,我们需要一种更加确定、可控的等待方案。

堵塞

在操作系统的协助下,我们可以对于等待进行更加精细的控制。为了避免等待事件的线程在事件到来之前被调度到而产生大量上下文切换开销,我们可以新增一种 阻塞 (Blocking) 机制。

当线程需要等待事件到来的时候,操作系统可以将该线程标记为阻塞状态 (Blocked) 并将其从调度器的就绪队列中移除。由于操作系统每次只会从就绪队列中选择一个线程分配 CPU 资源,被阻塞的线程就不再会获得 CPU 使用权,也就避免了上下文切换

相对的,在线程要等待的事件到来之后,我们需要解除线程的阻塞状态,将线程状态改成就绪状态,并将线程重新加入到就绪队列,使其有资格得到 CPU 资源。这就是与阻塞机制配套的唤醒机制。在线程被唤醒之后,由于它所等待的事件已经出现,在操作系统调度到它之后它就可以继续向下运行了。

相比 yield ,这种做法的可控性更好。

阻塞机制的缺点在于会不可避免的产生两次上下文切换。站在等待的线程的视角,它会被切换出去再切换回来然后再继续执行。在事件产生频率较低、事件到来速度比较慢的情况下这不是问题,但当事件产生频率很高的时候直接忙等也许是更好的选择。此外,阻塞机制相对比较复杂,需要操作系统的支持。

在lab8上实现基于堵塞的互斥锁

https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter8/2lock.html#id15

为什么同样使用单标记,这里却无需用到原子操作?

这里我们仅用到单标记 locked ,为什么无需使用原子指令来保证对于 locked 本身访问的互斥性呢?这其实是因为,RISC-V 架构规定从用户态陷入内核态之后所有(内核态)中断默认被自动屏蔽,也就是说与应用的执行不同, 目前系统调用的执行是不会被中断打断的 。同时,目前我们是在单核上,也 不会有多个 CPU 同时执行系统调用的情况 。在这种情况下,内核态的共享数据访问就仍在 UPSafeCell 的框架之内,只要使用它就能保证互斥访问。

同步互斥

同步 (Synchronization) 和 互斥 (Mutual Exclusion) 事实上是在多线程并发访问过程中出现的两种不同需求。同步指的是线程执行顺序上的一些约束,比如一个线程必须等待另一个线程执行到某个阶段之后才能继续向下执行;而互斥指的是多线程在访问共享资源的时候,同一时间最多只有一个线程能够在共享资源的临界区中

同步和互斥的界限其实比较模糊。比如,互斥也可以看成是一种同步需求,即一个线程在进入临界区之前必须等待当前在临界区中的线程(如果存在)退出临界区;而对于一种特定的同步需求,线程间也往往需要某些共享状态,线程需要通过查看或修改这些共享状态来进入等待或唤醒等待的线程。为了能够正确访问这些共享状态,就需要互斥。所以,二者之间是一种“你中有我,我中有你”的关系,我们就将这两种需求统称为 同步互斥 ,而将针对于这种需求比较通用的解决方案称之为 同步互斥原语 (简称为 同步原语 ,英文 Synchronization Primitives )。

信号量

信号量支持两种操作:P 操作(来自荷兰语中的 Proberen ,意为尝试)和 V 操作(来自荷兰语中的 Verhogen ,意为增加)

pass

管程与条件变量

信号量的局限性

  • 信号量本质上是一个整数,它不足以描述所有类型的等待条件/事件;

  • 在使用信号量的时候需要特别小心。比如,up 和 down 操作必须配对使用。而且在和互斥锁组合使用的时候需要注意操作顺序,不然容易导致死锁。

针对这种情况,xx提出了一种高级同步原语,称为 管程 (Monitor)。

管程是一个由过程(Procedures,是 Pascal 语言中的术语,等同于我们今天所说的函数)、共享变量及数据结构等组成的一个集合,体现了面向对象思想

编程语言负责提供管程的底层机制,程序员则可以根据需求设计自己的管程,包括自定义管程中的过程和共享资源。在管程帮助下,线程可以更加方便、安全、高效地进行协作:线程只需调用管程中的过程即可,过程会对管程中线程间的共享资源进行操作。需要注意的是,管程中的共享资源不允许直接访问,而是只能通过管程中的过程间接访问,这是在编程语言层面对共享资源的一种保护,与 C++/Java 等语言中类的私有成员类似。

语义

image-20240125120436431

前两节的互斥锁和信号量就是基于 Hansen 语义实现的

条件变量的引出

static mut A: usize = 0;
unsafe fn first() -> ! {
    A = 1;
    ...
}

unsafe fn second() -> ! {
    while A == 0 {
      // 忙等直到 A==1
    };
    //继续执行相关事务
}

这里的同步需求是第二个线程必须等待第一个线程将 A 修改成 1 之后再继续执行。

  • 我们尝试互斥锁
unsafe fn first() -> ! {
    mutex_lock(MUTEX_ID);
    A = 1;
    mutex_unlock(MUTEX_ID);
    ...
}

unsafe fn second() -> ! {
    mutex_lock(MUTEX_ID);
    while A == 0 { }
    mutex_unlock(MUTEX_ID);
    //继续执行相关事务
}

然而,这种实现并不正确。假设执行 second 的线程先拿到锁,那么它需要等到执行 first 的线程将 A 改成 1 之后才能退出忙等并释放锁。然而,由于线程 second 一开始就拿着锁也不会释放,线程 first 无法拿到锁并修改 A 。这样,实际上构成了死锁,线程 first 可能被阻塞,而线程 second 一直在忙等,两个线程无法做任何有意义的事情。

  • 我们需要修改 second 中忙等时锁的使用方式
unsafe fn second() -> ! {
    loop {
        mutex_lock(MUTEX_ID);
        if A == 0 {
            mutex_unlock(MUTEX_ID);
        } else {
            mutex_unlock(MUTEX_ID);
            break;
        }
    }
    //继续执行相关事务
}

在这种实现中,我们对忙等循环中的每一次对 A 的读取独立加锁。这样的话,当 second 线程发现 first 还没有对 A 进行修改的时候,就可以先将锁释放让 first 可以进行修改。这种实现是正确的,但是基于忙等会浪费大量 CPU 资源和产生不必要的上下文切换

  • 利用基于阻塞机制的信号量进一步进行改造:
// user/src/bin/condsync_sem.rs

unsafe fn first() -> ! {
    mutex_lock(MUTEX_ID);
    A = 1;
    semaphore_up(SEM_ID);
    mutex_unlock(MUTEX_ID);
    ...
}

unsafe fn second() -> ! {
    loop {
        mutex_lock(MUTEX_ID);
        if A == 0 {
            mutex_unlock(MUTEX_ID);
            semaphore_down(SEM_ID);
        } else {
            mutex_unlock(MUTEX_ID);
            break;
        }
    }
    //继续执行相关事务
}

互斥锁和信号量能实现很多功能,但是它们对于程序员的要求较高,一旦使用不当就很容易出现难以调试的死锁问题。对于这种比较复杂的同步互斥问题,就可以用本节介绍的条件变量来解决

  • 管程与条件变量
// user/src/bin/condsync_condvar.rs

const CONDVAR_ID: usize = 0;
const MUTEX_ID: usize = 0;

unsafe fn first() -> ! {
    sleep(10);
    println!("First work, Change A --> 1 and wakeup Second");
    mutex_lock(MUTEX_ID);
    A = 1;
    condvar_signal(CONDVAR_ID);
    mutex_unlock(MUTEX_ID);
    exit(0)
}

unsafe fn second() -> ! {
    println!("Second want to continue,but need to wait A=1");
    mutex_lock(MUTEX_ID);
    while A == 0 {
        println!("Second: A is {}", A);
        condvar_wait(CONDVAR_ID, MUTEX_ID);
    }
    println!("A is {}, Second can work now", A);
    mutex_unlock(MUTEX_ID);
    exit(0)
}

#[no_mangle]
pub fn main() -> i32 {
    // create condvar & mutex
    assert_eq!(condvar_create() as usize, CONDVAR_ID);
    assert_eq!(mutex_blocking_create() as usize, MUTEX_ID);
    ...
}

银行家算法

算法的核心是判断满足线程的资源请求是否会导致整个系统进入不安全状态。如果是,就拒绝线程的资源请求;如果满足请求后系统状态仍然是安全的,就分配资源给线程。

状态是安全的,是指存在一个资源分配/线程执行序列使得所有的线程都能获取其所需资源并完成线程的工作。如果找不到这样的资源分配/线程执行序列,那么状态是不安全的>。这里把线程的执行过程简化为:申请资源、释放资源的一系列资源操作。这意味这线程执行完毕后,会释放其占用的所有资源。

我们需要知道,不安全状态并不等于死锁而是指有死锁的可能性。安全状态和不安全状态的区别是:从安全状态出发,操作系统通过调度线程执行序列,能够保证所有线程都能完成,一定不会出现死锁;而从不安全状态出发,就没有这样的保证,可能出现死锁。

rust

易失性读写 read/write_volatile

有些时候,编译器会对一些访存行为进行优化。举例来说,如果我们写入一个内存位置并立即读取该位置,并且在同段时间内其他线程不会访问该内存位置,这意味着我们写入的值能够在 RAM 上保持不变。那么,编译器可能会认为读取到的值必定是此前写入的值,于是在最终的汇编码中读取内存的操作可能被优化掉。然而,有些时候,特别是访问 I/O 外设以 MMIO 方式映射的设备寄存器时,即使是相同的内存位置,对它进行读取和写入的含义可能完全不同,于是读取到的值和我们之前写入的值可能没有任何关系。连续两次读取同个设备寄存器也可能得到不同的结果。这种情况下,编译器对访存行为的修改显然是一种误优化。

于是,在访问 I/O 设备寄存器或是与 RAM 特性不同的内存区域时,就要注意通过 read/write_volatile 来确保编译器完全按照我们的源代码生成汇编代码而不会自作主张进行删除或者重排访存操作等优化。

SegQueue 容器与vecdeque容器

VDeque:

双端队列这样做会报错

use std::sync::Arc;
use std::collections::VecDeque;

fn main() {
    let queue = Arc::new(VecDeque::new());
    
    // 下面的操作会导致编译错误
    queue.push_back(1);
}

但是队列不会报错

use crossbeam_queue::SegQueue;
use std::sync::{Arc};
fn main() {
    let x = Arc::new(SegQueue::new());
    x.push(10);
    x.push(20);
    x.push(30);
    let y = Arc::clone(&x);
    y.push(45);
    for _ in 0..y.len(){
        println!("{:?}",y.pop());
    }
}

这是因为crossbeam_queue::SegQueue 的行为和常规的队列有所不同,这是因为 SegQueue 已经是为并发设计的,并内置了线程安全的机制。因此,当您通过 Arc 共享 SegQueue 时,您可以在不同线程之间安全地进行数据的推送(push)和弹出(pop)操作。

流程

基于阻塞机制实现 sleep 系统调用

  • 用户调用Sleep函数,线程状态被设置成block,将线程控制块插入小根堆中,并设置超时时间

  • 作者维护了一个小根堆,每次时间片切换的时候都从小根堆中获取所有超时的任务,并插入到调度队列中

在os中实现基于堵塞的锁

os采用单标记法实现锁

为什么内核使用单标记locked却不用使用原子指令的原因:

这里我们仅用到单标记 locked ,为什么无需使用原子指令来保证对于 locked 本身访问的互斥性呢?这其实是因为,RISC-V 架构规定从用户态陷入内核态之后所有(内核态)中断默认被自动屏蔽,也就是说与应用的执行不同, 目前系统调用的执行是不会被中断打断的 。同时,目前我们是在单核上,也 不会有多个 CPU 同时执行系统调用的情况 。在这种情况下,内核态的共享数据访问就仍在 UPSafeCell 的框架之内,只要使用它就能保证互斥访问。

信号量机制与条件变量

两者的关系:

https://www.zhihu.com/question/481951579

感觉和rCore的实现有些出入,上面说signal操作会释放所有被堵塞线程来着...

rust标准库里没有实现信号量,不过实现了条件变量,一个标准的条件变量例子:


use std::sync::{Arc, Mutex, Condvar};
use std::thread;

struct SharedBuffer {
    buffer: Vec<i32>,
    condvar: Condvar,
}

fn main() {
    let shared = Arc::new(Mutex::new(SharedBuffer { buffer: vec![], condvar: Condvar::new() }));

    // 生产者线程
    let shared_producer = Arc::clone(&shared);
    thread::spawn(move || {
        let mut shared_buffer = shared_producer.lock().unwrap();
        shared_buffer.buffer.push(1); // 生产一个元素
        shared_buffer.condvar.notify_one(); // 通知消费者
    });

    // 消费者线程
    let shared_consumer = Arc::clone(&shared);
    thread::spawn(move || {
        let mut shared_buffer = shared_consumer.lock().unwrap();
        while shared_buffer.buffer.is_empty() {
            shared_buffer = shared_buffer.condvar.wait(shared_buffer).unwrap(); // 等待直到缓冲区非空
        }
        println!("消费的元素: {:?}", shared_buffer.buffer.pop());
    });
}

看着与标准的条件变量的实现不太一样哈

#![no_std]
#![no_main]

#[macro_use]
extern crate user_lib;

extern crate alloc;

use alloc::vec;
use user_lib::exit;
use user_lib::{
    condvar_create, condvar_signal, condvar_wait, mutex_blocking_create, mutex_lock, mutex_unlock,
};
use user_lib::{sleep, thread_create, waittid};

static mut A: usize = 0;

const CONDVAR_ID: usize = 0;
const MUTEX_ID: usize = 0;

unsafe fn first() -> ! {
    sleep(10);
    println!("First work, Change A --> 1 and wakeup Second");
    mutex_lock(MUTEX_ID);
    A = 1;
    condvar_signal(CONDVAR_ID);         //唤醒一个在该条件变量上阻塞的线程(如果存在)。
    mutex_unlock(MUTEX_ID);
    exit(0)
}

unsafe fn second() -> ! {
    println!("Second want to continue,but need to wait A=1");
    mutex_lock(MUTEX_ID);
    while A == 0 {
        println!("Second: A is {}", A);
        condvar_wait(CONDVAR_ID, MUTEX_ID);
    }
    println!("A is {}, Second can work now", A);
    mutex_unlock(MUTEX_ID);
    exit(0)
}

#[no_mangle]
pub fn main() -> i32 {
    // create condvar & mutex
    assert_eq!(condvar_create() as usize, CONDVAR_ID);
    assert_eq!(mutex_blocking_create() as usize, MUTEX_ID);
    // create threads
    let threads = vec![
        thread_create(first as usize, 0),
        thread_create(second as usize, 0),
    ];
    // wait for all threads to complete
    for thread in threads.iter() {
        waittid(*thread as usize);
    }
    println!("test_condvar passed!");
    0
}

int value = xx;
sem s;
Mutex mutex;

//消费者
mutex.lock();
while(value==0){
    wait(s.mutex);
}
mutex.unlock();
value--;

wait(sem* S,sem* mutex){ 
    add this process to S->list;
    mutex.unlock();
    block();
    mutex.lock();
} 

//生产者
mutex.lock();
value++;
mutex.unlock();
signal(s);         
signal(sem* S){
        if(S->list非空){
            for(auto i in S->list){
                wakeup(i);
            }
        }  
}

// 作者:BBAB
// 链接:https://www.zhihu.com/question/481951579/answer/2291116376

实际上,在 Rust 中,当通过 Mutex<T>lock 方法获取到锁时,这实际上返回一个智能指针 MutexGuard。当这个 MutexGuard 离开其作用域并被销毁时,锁会自动释放。这是 Rust 借用检查器和所有权系统的一个优点,它能够确保在锁的持有者离开作用域时自动释放锁,从而避免死锁。

在我的例子中,当线程的闭包(即 {} 内的代码块)执行完毕时,shared_buffer(一个 MutexGuard 类型的变量)会离开其作用域。在这个时刻,MutexGuardDrop 实现会被调用,这会自动释放之前获得的锁。因此,你不需要手动调用 unlock,Rust 的所有权和借用规则会自动处理这个过程。

条件变量的内核实现

pub fn sys_condvar_create() -> isize {
    let process = current_process();
    let mut process_inner = process.inner_exclusive_access();
    let id = if let Some(id) = process_inner
        .condvar_list
        .iter()
        .enumerate()
        .find(|(_, item)| item.is_none())
        .map(|(id, _)| id)
    {
        process_inner.condvar_list[id] = Some(Arc::new(Condvar::new()));
        id
    } else {
        process_inner
            .condvar_list
            .push(Some(Arc::new(Condvar::new())));
        process_inner.condvar_list.len() - 1
    };
    id as isize
}

pub fn sys_condvar_signal(condvar_id: usize) -> isize {
    let process = current_process();
    let process_inner = process.inner_exclusive_access();
    let condvar = Arc::clone(process_inner.condvar_list[condvar_id].as_ref().unwrap());
    drop(process_inner);
    condvar.signal();
    0
}

pub fn sys_condvar_wait(condvar_id: usize, mutex_id: usize) -> isize {
    let process = current_process();
    let process_inner = process.inner_exclusive_access();
    let condvar = Arc::clone(process_inner.condvar_list[condvar_id].as_ref().unwrap());
    let mutex = Arc::clone(process_inner.mutex_list[mutex_id].as_ref().unwrap());
    drop(process_inner);
    condvar.wait(mutex);
    0
}


impl Condvar {
    pub fn new() -> Self {
        Self {
            inner: unsafe {
                UPSafeCell::new(CondvarInner {
                    wait_queue: VecDeque::new(),
                })
            },
        }
    }

    pub fn signal(&self) {
        let mut inner = self.inner.exclusive_access();
        if let Some(task) = inner.wait_queue.pop_front() {
            wakeup_task(task);
        }
    }

    pub fn wait(&self, mutex: Arc<dyn Mutex>) {
        mutex.unlock();
        let mut inner = self.inner.exclusive_access();
        inner.wait_queue.push_back(current_task().unwrap());
        drop(inner);
        block_current_and_run_next();
        mutex.lock();
    }
}

问答题

1、什么是并行?什么是并发?

并行是真正意义上的同时执行,强调同时性

并发是指系统能够处理多个任务的能力,这些任务可以交替执行或部分重叠执行,但并不一定同时执行。并发强调的是任务的处理顺序

  • “并行” 指的是同时进行多个任务。在多 CPU 环境中,计算机具有多个独立的 CPU,可以同时执行多个任务。例如,如果你有两个 CPU,那么它们可以同时运行两个不同的程序,这样它们就是并行的。
  • “并发” 指的是多个任务的同时发生,但它们不一定是同时执行的。在单 CPU 环境中,并发和并行是通过 CPU 快速地在多个任务之间切换来模拟同时发生的效果。例如,如果你在同时运行多个程序,那么 CPU 可以快速地在这些程序之间切换,从而模拟它们同时发生的效果。这种情况下,这些程序是并发的,但不是并行的。

2、为了创造临界区,单核处理器上可以【关中断】,多核处理器上需要使用【自旋锁】。请回答下列问题:

  • 多核上可不可以只用【关中断】?

    不可以,多核涉及多个CPU,每个CPU都有中断处理寄存器,关中断只能保证当前核上的代码不会被中断。

    比如一个进程,其多个线程分别在不同核下运行,在只用关中断的情况下不同核的线程会同时进入临界区

  • 单核上可不可以只用【自旋锁】?

    可以

  • 多核上的【自旋锁】是否需要同时【关中断】?

     对于多核处理器上的自旋锁,通常不需要关中断来创建临界区。相反,自旋锁的实现会使用处理器提供的硬件特性来确保原子性,例如原子操作、内存屏障等。这种方式能够避免全局中断,从而提高系统的性能。

  • [进阶] 假如某个锁不会在中断处理函数中被访问,是否还需要【关中断】?

    • 在单核处理器上,如果所有的代码都是在同一个上下文中运行,也就是没有中断或者线程切换的情况下,如果在代码中使用锁来保护共享资源,那么可以使用简单的互斥锁来实现临界区的保护,而不需要关中断。
    • 在多核处理器上,不同的核心可以独立运行不同的线程,彼此之间不会互相干扰。在这种情况下,可以使用自旋锁等更高效的同步机制来实现临界区的保护。如果代码中使用的锁需要在中断处理函数中被访问,那么在多核处理器上需要关中断来保护临界区。在中断处理函数中,由于上下文的切换,可能会发生竞争条件,因此需要通过关中断的方式来避免这种竞争。这样可以保证在中断处理函数执行期间,不会有其他线程在访问共享资源,从而保证临界区的安全性。

3、Linux的多线程应用程序使用的锁(例如 pthread_mutex_t)不是自旋锁,当上锁失败时会切换到其它进程执行。分析它和自旋锁的优劣,并说明为什么它不用自旋锁?

虽然信号量、条件变量、自旋锁和互斥锁都是并发编程中用于同步和保护共享资源的机制,但它们各自适用于不同的场景和需求。这些同步原语的选择取决于所面临的具体问题、性能要求、资源使用效率和易用性。下面是这些同步机制的基本特点和适用场景:

互斥锁(Mutex)

  • 特点:互斥锁提供了对共享资源的互斥访问。当一个线程锁定互斥锁时,其他试图访问该锁的线程将被阻塞,直到互斥锁被释放。
  • 适用场景:适用于保护对共享资源的访问,特别是当这些操作需要较长时间时。

自旋锁(Spinlock)

  • 特点:自旋锁是一种忙等(busy-wait)锁。当一个线程尝试获取已被另一个线程占用的锁时,它将在一个循环中不断检查锁的状态,而不是休眠
  • 适用场景:适用于那些锁的持有时间非常短的场合,因为它们可以避免线程上下文切换的开销。

互斥锁和自旋锁的优劣:互斥锁和自旋锁的本质区别在于加锁失败时,是否会释放CPU。互斥锁在加锁失败时,会释放CPU,因此与自旋锁相比它的主要优势在于可以提高处理器的资源利用率,避免CPU空转的现象,但与之带来的是互斥锁的开销更大。这些开销主要包括两次线程上下文切换的成本:

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

不使用自旋锁的原因是:

  • 可移植性:pthread_mutex_t是POSIX标准中定义的一种互斥锁,不仅可以在Linux系统上使用,还可以在其他的POSIX兼容系统上使用,提高了应用程序的可移植性。
  • 性能:自旋锁在多核处理器上可以提高并发性能,但是在单核处理器上可能会降低性能,因为自旋锁需要不断地检查锁的状态,如果锁一直处于被占用的状态,就会一直占用处理器时间。而pthread_mutex_t是一种阻塞锁,在锁被占用时,会将线程挂起,让出处理器时间,从而避免了空转浪费处理器资源的情况。
  • 死锁:使用自旋锁需要非常小心,否则容易出现死锁的情况。例如,当一个线程持有一个自旋锁并等待另一个自旋锁时,如果另一个线程持有了这个自旋锁并等待第一个自旋锁,就会出现死锁。而pthread_mutex_t是一种阻塞锁,在锁的等待队列中维护了线程的等待关系,可以避免死锁的情况。

4、程序在运行时具有两种性质:safety: something bad will never happen;liveness: something good will eventually occur. 分析并证明 Peterson 算法的 safety 和 liveness 性质

image-20240125164702971

5、信号量结构中的整数分别为+n、0、-n 的时候,各自代表什么状态或含义?

  • +n:还有 n 个可用资源
  • 0:所有可用资源恰好耗尽
  • -n:有n个进程申请了资源但无资源可用,被阻塞。

6、考虑如下信号量实现代码:

class Semaphore {
  int sem;
  WaitQueue q;
}
Semaphore::P() {
  sem --;
  if(sem < 0) {
    Add this thread to q.
    block.
  }
}
Semaphore::V() {
  sem ++;
  if(sem <= 0) {
    t = Remove a thread from q;
    wakeup(t);
  }
}

假如 P操作或V操作不是原子操作,会出现什么问题?举一个例子说明。上述代码能否运行在用户态?上面代码的原子性是如何保证的?

如果P操作或V操作不是原子操作,将无法实现资源的互斥访问。

P/V操作在rCore下是通过关中断实现的,故不能运行在用户态

7、条件变量的 Wait 操作为什么必须关联一个锁?

当调用条件变量的 wait 操作阻塞当前线程的时候,该操作是在管程过程中,因此此时当前线程持有锁。在持有锁的情况下不能陷入阻塞 ,因此在陷入阻塞状态之前当前线程必须先释放锁;当被阻塞的线程被其他线程使用 signal 操作唤醒之后,需要重新获取到锁才能继续执行,不然的话就无法保证管程过程的互斥访问。

因此,站在线程的视角,必须持有锁才能调用条件变量的 wait 操作阻塞自身。

8、下面是条件变量的wait操作实现伪代码:

Condvar::wait(lock) {
  Add this thread to q.
  lock.unlock();
  schedule();
  lock.lock();
}

如果改成下面这样:

Condvar::wait() {
  Add this thread to q.
  schedule();
}
lock.unlock();
condvar.wait();
lock.lock();

会出现什么问题?举一个例子说明。

这种情况就是第7题提到的条件变量的wait操作没有关联一个锁。会造成被阻塞的线程被其他线程使用 signal 操作唤醒之后,无法获取锁,从而无法保证管程过程的互斥访问,导致管程失效

9、死锁的必要条件是什么?

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能被其他进程强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

10、什么是死锁预防,举例并分析。

破坏四个条件之一即可

实验题

实验题一

背景:在智能体大赛平台 Saiblo 网站上每打完一场双人天梯比赛后需要用 ELO 算法更新双方比分。由于 Saiblo 的评测机并发性很高,且 ELO 算法中的分值变动与双方变动前的分数有关,因此更新比分前时必须先为两位选手加锁。

作业:请模拟一下上述分数更新过程,简便起见我们简化为有 p 位选手参赛(编号 [0, p) 或 [1, p] ),初始分值为 1000 分,有 m 个评测机线程(生产者)给出随机的评测结果(两位不同选手的编号以及胜负结果,结果可能为平局),有 n 个 worker 线程(消费者)获取结果队列并更新数据库(全局变量等共享数据)记录的分数。m 个评测机各自模拟 k 场对局结果后结束线程,全部对局比分更新完成后主线程打印每位选手最终成绩以及所有选手分数之和。

上述参数 p、m、n、k 均为可配置参数(命令行传参或程序启动时从stdin输入)。

简便起见不使用 ELO 算法,简化更新规则为:若不为平局,当 胜者分数 >= 败者分数 时胜者 +20,败者 -20,否则胜者 +30,败者 -30;若为平局,分高者 -10,分低者+10(若本就同分保持则不变)。

  • 消费者核心部分可参考如下伪码:

    获取选手A的锁 获取选手B的锁 更新A、B分数 睡眠 1ms(模拟数据库更新延时) 释放选手B的锁 释放选手A的锁

  • tips:

    由于 ELO 以及本题中给出的简化更新算法均为零和算法,因此出现冲突后可以从所有选手分数之和明显看出来,正确处理时它应该永远为 1000p将一个 worker 线程看作哲学家,将 worker 正在处理的一场对局的两位选手看作两根筷子,则得到了经典的哲学家就餐问题

image-20240125223508471

github: https://github.com/TL-SN/rCore/tree/lab8/Job1_windows

main.rs:

use std::{thread,time};
use rand::Rng;
use std::sync::{Arc, Mutex, Condvar};
use crossbeam_queue::SegQueue;
use once_cell::sync::Lazy;

const BIGPOINTS : usize = 30;
const MIDPOINTS : usize = 20;
const LITTLEPOINTS : usize  = 10;
const TIEGAMEPOINTS : usize = 0;
struct Lombard{
    p : usize,              // 选手个数(筷子数)
    m : usize,              // 评测机线程 (生产者,线程个数) , 给出随机的评测结果(两位不同选手的编号以及胜负结果,结果可能为平局)
    n : usize,              // worker线程(消费者,哲学家数量), 获取结果队列并更新数据库(全局变量等共享数据)记录的分数
    k : usize,              // 每个线程执行的对局数目
}

static GLOBAL_MUTEX_LOCK1: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));     // 全局互斥锁1
static GLOBAL_MUTEX_LOCK2: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));     // 全局互斥锁2
static GLOBAL_COUNTER: Lazy<Mutex<usize>> = Lazy::new(|| Mutex::new(0));        // 全局互斥计数器
#[derive(Debug)]
struct Player{
    is_using: bool,
    point : isize,
}

struct Competitors{
    player1: usize,
    player2: usize,
}

#[derive(Debug)]
struct Score{
    score: Vec<Mutex<Player>>,
    condvars: Vec<Condvar>,
    competitors: SegQueue<Competitors>,         // SegQueue本身就支持高并发
}

impl Score {
    fn new(people:usize) -> Score{
        Score { 
            score: (0..people).map(|_| Mutex::new(Player{is_using: false,point: 1000})).collect(),
            condvars: (0..people).map(|_| Condvar::new()).collect(),
            competitors: SegQueue::new(),
        }
    }

    fn get_point(&self){
        let mut sum = 0;
        for i in 0..self.score.len(){
            let sc = self.score[i].lock().unwrap();
            sum += sc.point;
            println!("Player[{:?}] score is {:?}",i,sc.point);
        }
        println!("All_points : {:?}",sum);
    }

    // 一次失败的尝试
    // fn join_in(&self,player1:usize,player2:usize){
    //     let left = player1;
    //     let right = player2;
        
    //     {
    //         let mut left_lock = self.score[left].lock().unwrap();
    //         while left_lock.is_using{                                       // 错误原因1: 它在 while 循环中用于条件判断时,会发生所有权移动的问题。Rust 的 MutexGuard 是一个 RAII(Resource Acquisition Is Initialization)守卫,它在作用域结束时自动释放锁。逆天错误
    //             left_lock = self.condvars[left].wait(left_lock).unwrap();
    //         }
    //         left_lock.is_using = true;
    //     }
        
    //     {
    //         let mut right_lock = self.score[right].lock().unwrap();
    //         while right_lock.is_using {
    //             right_lock = self.condvars[right].wait(right_lock).unwrap();
    //         }
    //         right_lock.is_using = true;
            
    //     }

    //     self.competitors.push( Competitors{player1: left,player2:right} );  // 
    //     thread::sleep(time::Duration::from_millis(20));  // 比赛                                                                         // V(player1)
    //                                                                                         // V(player2)
    // }                                                                                       // drop
    

    // 上面的方法任然会导致死锁,测试了2000次,死锁了13次
    // 原因如下:
    // 虽然我们把锁放到了作用域里面,进行了及时的销毁,但是这里不同与record_points 函数,依然会出现交叉占用,形成互锁
    // 考虑这种情况:
    // 线程1: join_in(10,20); 
    // 线程2: join_in(20,10);
    // 线程1先锁住10号,并准备去锁20号
    // 线程2同时锁住了20号,准备去锁10号
    // 这时线程1、2会陷入while循环中,不断让出CPU空间,无法再前进一步,这就由陷入了哲学家进餐问题的经典错误之中,我们可以用两种方法来解决:
    // 1、永远都是先锁号小的,再锁号大的
    // 2、添加一个全局互斥锁,参考2023年王道操作系统 P103页。
    
    // 第一个解决方法
    // fn join_in(&self, player1: usize, player2: usize) {
    //     let (first, second) = if player1 < player2 { (player1, player2) } else { (player2, player1) };
        
    //     let mut first_lock = self.score[first].lock().unwrap();
    //     while first_lock.is_using {
    //         first_lock = self.condvars[first].wait(first_lock).unwrap();
    //     }
    //     first_lock.is_using = true;
    
    //     let mut second_lock = self.score[second].lock().unwrap();
    //     while second_lock.is_using {
    //         second_lock = self.condvars[second].wait(second_lock).unwrap();
    //     }
    //     second_lock.is_using = true;
    
    //     self.competitors.push(Competitors { player1: first, player2: second });
    //     thread::sleep(time::Duration::from_millis(20));
    // }

    // 第二个解决方法
    fn join_in(&self,player1:usize,player2:usize){
        let left = player1;
        let right = player2;
        
        let _lock = GLOBAL_MUTEX_LOCK2.lock().unwrap();
        {
            let mut left_lock = self.score[left].lock().unwrap();
            while left_lock.is_using{                                       // 错误原因1: 它在 while 循环中用于条件判断时,会发生所有权移动的问题。Rust 的 MutexGuard 是一个 RAII(Resource Acquisition Is Initialization)守卫,它在作用域结束时自动释放锁。
                left_lock = self.condvars[left].wait(left_lock).unwrap();
            }
            left_lock.is_using = true;
        }
        
        {
            let mut right_lock = self.score[right].lock().unwrap();
            while right_lock.is_using {
                right_lock = self.condvars[right].wait(right_lock).unwrap();
            }
            right_lock.is_using = true;
            
        }
        drop(_lock);
        self.competitors.push( Competitors{player1: left,player2:right} );  // 
        thread::sleep(time::Duration::from_millis(20));  // 比赛                                                                         // V(player1)
                                                                                            // V(player2)
    } 
                                                                               
    

    // 这里我们通过作用域的方式,把mutex锁进行了及时的销毁,避免出现交叉占用锁的情况
    fn record_points(&self,player1:usize,player2:usize){
        let left = player1;
        let right = player2;        
        
        let mut score1 ;
        let mut score2 ;
        {
            let left_lock = self.score[left].lock().unwrap();   // P(player1)
            score1 = left_lock.point;
        }
        

        {
            let right_lock = self.score[right].lock().unwrap(); // P(player2)
            score2 = right_lock.point;   
        }
        

        let points = get_point(score1,score2);
        score1 += points.0;
        score2 += points.1;
        
        
        {
            let mut left_lock = self.score[left].lock().unwrap();   // P(player1)
            left_lock.is_using = false;
            left_lock.point = score1;
        }
        
        {
            let mut right_lock = self.score[right].lock().unwrap(); // P(player2)
            right_lock.is_using = false;
            right_lock.point = score2;
        }

        
        

        self.condvars[left].notify_all();
        self.condvars[right].notify_all(); 
        
        let mut count = GLOBAL_COUNTER.lock().unwrap();
        *count += 1;

        // println!("pop {:?}",count);         // 正常情况下,最后count是Lombard.m * Lombard.k的大小
        if *count == 200{
            // self.get_point();
            println!("pop {:?}",count);
        }
    }
}


fn start_contest(contest : Arc<Score>,times:usize,number : usize){
    
    for _ in 0..times{
        let num = get_two_random_nums(number);
        let player1 = num.0 ;
        let player2 = num.1 ;

        contest.join_in(player1, player2);
    }
    
}

fn end_contest(contest : Arc<Score>,expect_times:usize){
    
    while true{
        // 加锁 
        let _lock = GLOBAL_MUTEX_LOCK1.lock().unwrap();
        if contest.competitors.len() != 0{
            
            // drop(_lock)                                         // 错因: 得等到pop之后再drop
            let players = contest.competitors.pop().unwrap();
            let left = players.player1;
            let right = players.player2;
            drop(_lock);                                        
            contest.record_points(left, right);
        }else{
            
            drop(_lock);  

            let _count = GLOBAL_COUNTER.lock().unwrap();
            if *_count >= expect_times{
                drop(_count);
                break;
            }
            drop(_count);
            thread::sleep(time::Duration::from_millis(20));
        
        }
    }

}


fn get_two_random_nums(number : usize) -> (usize, usize){
    let mut rng = rand::thread_rng();
    let num1 = rng.gen_range(0..number);
    let mut num2:usize;
    loop {
        num2 =  rng.gen_range(0..number);
        if num1 != num2 {
            break;
        }
    }
    return (num1,num2)
}

fn get_winer() -> usize{
    let mut rng = rand::thread_rng();
    let fin = rng.gen_range(0..3);
    fin as usize
}
fn get_point(score1 :isize,score2:isize) ->(isize,isize) {
// 0代表 平局,1代表player1胜利,2代表player2胜利 
    let win = get_winer();
    if score1 == score2 && win == 0{                            // 1_1、平局且分数相等
        return (TIEGAMEPOINTS as isize,TIEGAMEPOINTS as isize);
    }else if score1 > score2 && win == 0  {                     // 1_2、平局但第一个玩家的分数基数大
        return (LITTLEPOINTS as isize,-(LITTLEPOINTS as isize));
    }else if score1 < score2 && win == 0 {
        return (-(LITTLEPOINTS as isize),LITTLEPOINTS as isize); // // 1_3、平局但第一个玩家的分数基数大
    }

    if score1 >= score2 && win == 1{
        return (MIDPOINTS as isize,-(MIDPOINTS as isize));
    }else if score1 >= score2 && win == 2  {
        return (-(BIGPOINTS as isize),BIGPOINTS as isize);
    }

    if score1 <= score2 && win == 1{
        return (BIGPOINTS as isize,-(BIGPOINTS as isize));
    }else if score1 <= score2 && win == 2 {
        return (-(MIDPOINTS as isize),MIDPOINTS as isize);
    }
    
    (-1,-1)
}

fn main(){
    let saiblo  = Lombard{
        p : 100,
        m : 20,
        n : 100,
        k : 10,
    };
    

    let contests = Arc::new(Score::new(saiblo.p));
    

    let mut cp = Vec::new();
    for _ in 0..saiblo.m{
        let contest = Arc::clone(&contests);
        cp.push(thread::spawn(move ||{
            
            start_contest(contest,saiblo.k,saiblo.p);

        }));
    }

    let mut workers = Vec::new();
    let expect_times = saiblo.m * saiblo.k;
    for _ in 0..saiblo.n{
        let contest = Arc::clone(&contests);
        workers.push(thread::spawn(move ||{
            end_contest(contest,expect_times);
        }));
    }


    for thd in cp{
        thd.join().unwrap();
    }
    
    for wk in workers{
        wk.join().unwrap();
    }
    
    
}

Cargo.toml:

[package]
name = "job1"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.8.4"
once_cell = "1.10.0" 
crossbeam-queue = "0.3"

死锁检测脚本:

import subprocess
import time

start_time = time.time()
path = r".\target\debug\job.exe"
count = 0
def exec_test():
    global path
    global count
    try:
        get_input = subprocess.run([path],
                                    timeout=2,
                                    stdout=subprocess.PIPE)
        print(f"==> {count} ",get_input.stdout)
    except:
        print(f"==> {count}  DeadLock panic!!!")    
    count += 1

def fuzz():
    for _ in range(0,2000):
        exec_test()
    end_time = time.time()
    print(end_time-start_time)

if __name__ == '__main__':
    fuzz()
    

经过脚本检测发现,不会出现死锁

第一个解决方法:

==> 1999  b'pop 200\n'
在2000次实验中一共发生了0次死锁
1294.612135887146

第二个解决方法:

==> 1996  b'pop 200\n'
==> 1997  b'pop 200\n'
==> 1998  b'pop 200\n'
==> 1999  b'pop 200\n'
在2000次实验中一共发生了0次死锁
753.4291915893555

第二个方法效率高了一倍(考虑python脚本启动程序的时间,效率可能更高)

在实际编程中,可以利用rust的作用域或者drop来及时销毁锁,这样可能帮助我们解决交叉互锁的问题,参考上面的record_points函数

实验题二

在 Linux 中有一种用于事件通知的文件描述符,称为 eventfd 。其核心是一个 64 位无符号整数的计数器,在非信号量模式下,若计数器值不为零,则 read 函数会从中读出计数值并将其清零,否则读取失败; write 函数将缓冲区中的数值加入到计数器中。在信号量模式下,若计数器值非零,则 read 操作将计数值减一,并返回 1 ; write 将计数值加一。我们将实现一个新的系统调用: sys_eventfd2 。

github: https://github.com/TL-SN/rCore/tree/lab8

posted @ 2024-01-28 21:56  TLSN  阅读(49)  评论(0编辑  收藏  举报