《操作系统导论》进程调度:线程API

《操作系统导论》进程调度:线程API

Tags
OS
Published
2023-01-31
Author
宿愿Cc

pthread_create 创建线程

 
  • thread:用于返回创建的线程的ID
  • arr: 用于指定的被创建的线程的属性,上面的函数中使用NULL,表示使用默认的属性
  • start_routine: 这是一个函数指针,指向线程被创建后要调用的函数
  • arg: 用于给线程传递参数,在本例中没有传递参数,所以使用了NULL
 
 

pthread_join 线程完成

有时候我们有些操作会需要等待线程完成,pthread提供了pthread_join()来等待线程完成。这个函数有两个参数,第一个参数是pthread_t类型,用来指定要等待的线程。这个变量是创建进程时是一样的,第二个参数是可以传一个指针变量,这个线程所执行函数的返回值会给到这个变量上(因为函数可以返回任意类型,所以是void指针)。
 

posix线程库有一些函数集是和锁🔐相关的,从而让进入临界区的代码片段互斥,互斥锁便是一种很常见的锁。
当一个线程想要访问某个受保护的资源时,它必须先获取相应的互斥锁,当完成资源的访问后,它必须释放互斥锁。
以下是最基本的俩个函数,lock和unlock。
如果我们事先知道有些代码是一个临界区,那么就需要锁来保护,这段代码大概会如下所示
这段代码表示,当在调用pthread_mutex_lock()时如果没有其它线程拥有锁,当前线程将会获取该锁并进入临界区。如果有其它线程持有该锁,那么当前线程将会(自旋)等待该锁被释放(其它线程调用释放该锁)。所以可能在给定的时间内,会有很多线程在这卡住,在获取锁的函数内部等待。一直等待持有该锁的线程调用释放锁。
遗憾的是,这段代码当前还存在两个问题。
 

问题一:初始化锁

第一个问题是锁缺乏正确的初始化(lack of proper initialization)。所有锁都需要正确的初始化,以确保它有正确的值,并在解锁和获取时按需要工作。 对于POSIX线程,有两种方式来初始化锁
  1. PTHREAD_MUTEX_INITIALIZER 初始化锁,这种初始化方式会将锁设置为默认值。
    1. pthread_mutex_init()动态初始化,一般会使用这种方式来初始化。
      1. 该函数的第一个参数是锁本身,第二个参数是一组可选属性(可查阅对应文档),传入NULL表示使用默认值。这个方式创建的锁,在锁被用完后需要调用pthread_mutex_destroy()
     

    问题二:检查错误

    第二个问题是在获取和释放锁的时候没有检查错误。就像UNIX系统中的任何库函数一样,这些函数是也是可能会失败的!如果没有正确的检查错误,可能会导致多个线程进入临界区。所以一般我们对这些调用做一些简单的封装,会对函数加上断言。
     

    关于锁的其它API

    除了获取lock()和释放锁unlock()之外,pthread还提供了其它与锁交互的函数。如下有两个函数:
    这两个函数也是用来获取锁。如果锁被占用,则trylock则会失败。timedlock会在成功或者超时后返回,以先发生的为准。因此,trylock看起来就像是超时时间为零的timedlock退化版本。一般来说,我们会比较少使用这俩个版本,但是在有些场景,为了避免卡在(可能无限期)获取锁的函数中时会用到,后续的章节会看到(当研究死锁时)。
     
    💡
    问:mutex是寄存器还是内存块? 昨天看到一道题,它既不是寄存器也不是物理上的内存块,而是操作系统提供的一种同步机制,用于保护共享资源的访问。 在操作系统中,mutex通常是通过特定的数据结构来实现的,例如在Linux中,常用的mutex实现是基于内核中的spinlock或者semaphore。 操作系统内核中实现 mutex 时常会使用诸如 “原子操作” 以及 “核心态的同步原语” 等技术。这些机制虽然可能会在底层用到寄存器和内存操作来确保锁的状态安全地更新,但 mutex 本身作为一个概念,更准确的描述应该是一种同步原语或同步机制
     

    条件变量

    条件变量(Condition Variable)是线程同步的另一种机制,通常与互斥锁(Mutex)结合使用,以避免线程在某些条件未满足时浪费CPU时间去轮询检查这些条件。
     
    在多线程程序设计中,经常会遇到这样的场景:一个线程依赖某个条件才能继续执行,比如等待某个资源变为可用或某个任务完成。如果这个条件不满足,线程就应该被挂起等待,直到某个其他线程修改了环境变量并满足了条件。这时后者线程会通知等待的线程,使得它们有机会再次检查条件是否已满足,并且继续执行。
    wait和signal是条件变量提供的两个主要api:
    • wait:这个操作会让调用它的线程放弃互斥锁并进入休眠,直到其他线程唤醒它。使用条件变量的 wait 操作时,线程应该已经获得了与之关联的互斥锁。 wait 操作在将线程休眠之前会自动释放互斥锁,并在条件满足从而线程被唤醒时,再次自动加锁,使得线程可以安全地访问共享变量。
    • signalbroadcast:这些操作用于唤醒一个或多个等待(休眠)在条件变量上的线程。调用者通常在修改了共享变量的状态,且可能影响等待线程的条件后进行通知。signal 通常只唤醒一个等待线程(如果有多个线程等待,系统会选择一个),而 broadcast 则尝试唤醒在该条件变量上所有等待的线程。
     
    在POSIX线程库(pthread)中,条件变量通常通过 pthread_cond_t 类型的变量来表示,并配合 pthread_mutex_t 类型表示的互斥锁来使用。下面是一个简化的使用示例:
    你可能会疑惑,为什么第一段代码中的wait需要传两个参数,而signal只需要一个参数。wait多了一个lock参数。
    这是为了让线程进入休眠时,还需要同时让该线程释放锁,且在线程被唤醒时,自动加上锁,从而确保在进入临界区时,它都持有锁。
     
    在其他线程中,可能会去改变条件(ready),并且需要通知等待的线程:
    这段代码有些值得注意的点。首先在修改全局变量ready和发出信号时,该线程一直都拥有锁,确保不会产生竞态条件(这和加锁时的描述是一样的)。
     
    💡
    为什么要在线程唤醒时再使用while检查一次? 使用while循环是一件简单且安全的事,等待线程会在while循环中重新检查条件,而不是不是简单的if语句。 因为有一些pthread实现可能会错误的唤醒等待的线程(此时可能值没有变,如在发送信号时,条件变量ready并没有修过),如果没有再次进行检查的话,那可能后续的计算执行结果是有误的。 因此,我们只能将唤醒视为可能发生变化的暗示,而不是确切的事,这样才能让代码更安全稳健。
     

    其它标记

    有时候我们会觉得使用条件变量和锁会使得代码写起来更繁琐,只用一个标记变量会简单很多,代码如下所示:
    相关的发信号代码如下
    千万别这么做!多数情况下性能很差(长时间自旋消耗CPU)。其次,很容易出bug。有一项研究表明,采用这种方式来线程同步,出错的可能性很恐怖。这项研究中还体现,使用这种不正规的同步方法有一半以上都是有问题的。因此,不要偷懒,就算你不想用,但条件变量依旧是目前最优解。
     

    线程API指导

    在使用POSIX线程库(以及任何线程库)来构建多线程程序时,有一些小准则可以让我们写出高质量的程序。
    • 保持简洁。这很重要,线程之间的锁和信号的代码要尽量简洁,复杂的线程交互很容易出bug。
    • 尽可能减少线程交互。尽量减少线程间的交互,每次交互都要想清楚,并且要仔细验证测试,且使用正确的方式来实现。
    • 初始化锁和条件变量。未初始化的代码有时候可以正常运行,有时会失败,产生奇怪的结果。
    • 检查返回值。不检查返回值可能会导致奇怪的bug
    • 注意传递个线程的参数和返回值。如果传递给栈上分配的变量的引用,那就会出问题
    • 每个线程都有自己的栈。类似上一条,时刻清晰每个线程都有自己的栈。因此线程的局部变量都是私有的,其它线程不可访问。线程间共享的数据需要在堆(heap)或者全局可访问的位置
    • 总是通过条件变量发射信号。切记不能使用标记变量来同步
    • 多查手册。多看Liunx和pthread手册,手册中有更多的细节