UIUC CS241 讲义:众包系统编程书(2/3)

60 阅读1小时+

原文:angrave/SystemProgramming

译者:飞龙

协议:CC BY-NC-SA 4.0

四、Pthreads 简介

Pthreads,第一部分:介绍

线程简介

什么是线程?

线程是“执行线程”的缩写。它表示 CPU 已经(并将)执行的指令序列。为了记住如何从函数调用返回,并存储自动变量和参数的值,线程使用堆栈。

轻量级进程(LWP)是什么?它与线程有什么关系?

对于所有目的和意图来说,线程就是一个进程(意味着创建线程类似于fork),只是没有复制,意味着没有写时复制。这允许进程共享相同的地址空间、变量、堆、文件描述符等。

创建线程的实际系统调用类似于fork;它是clone。我们不会深入讨论,但您可以阅读man pages,请记住这超出了本课程的直接范围。

在许多情况下,LWP 或线程比 forking 更受欢迎,因为创建它们的开销要少得多。但在某些情况下(特别是 Python 使用这种方式),多进程是使代码更快的方法。

线程的堆栈是如何工作的?

您的主函数(以及您可能调用的其他函数)具有自动变量。我们将使用堆栈将它们存储在内存中,并使用简单指针(“堆栈指针”)跟踪堆栈的大小。如果线程调用另一个函数,我们将将堆栈指针向下移动,以便我们有更多的空间用于参数和自动变量。一旦从函数返回,我们可以将堆栈指针移回到其先前的值。我们在堆栈上保留旧的堆栈指针值的副本!这就是为什么从函数返回非常快速的原因-释放自动变量使用的内存很容易-我们只需要更改堆栈指针。

在多线程程序中,有多个堆栈,但只有一个地址空间。pthread 库分配一些堆栈空间(可以在堆中分配,也可以使用主程序的堆栈的一部分),并使用clone函数调用在该堆栈地址启动线程。总地址空间可能看起来像这样。

我的进程可以有多少个线程?

您可以在一个进程内运行多个线程。您可以免费获得第一个线程!它运行您在“main”内编写的代码。如果您需要更多线程,可以使用 pthread 库调用pthread_create创建一个新线程。您需要传递一个指向函数的指针,以便线程知道从哪里开始。

您创建的所有线程都存在于相同的虚拟内存中,因为它们是同一进程的一部分。因此,它们都可以看到堆、全局变量和程序代码等。因此,您可以让两个(或更多)CPU 同时在同一进程中运行您的程序。由操作系统来分配线程给 CPU。如果活动线程多于 CPU,则内核将为线程分配一个 CPU 进行短暂的持续时间(或直到它没有要做的事情),然后将自动切换 CPU 以处理另一个线程。例如,一个 CPU 可能正在处理游戏 AI,而另一个线程正在计算图形输出。

简单用法

Hello world pthread 示例

要使用 pthread,您需要包括pthread.h,并且需要使用-pthread(或-lpthread)编译器选项进行编译。此选项告诉编译器您的程序需要线程支持

要创建线程,请使用函数pthread_create。此函数有四个参数:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
  • 第一个是指向将保存新创建的线程的 ID 的变量的指针。

  • 第二个是指向属性的指针,我们可以使用它来调整和调优一些 pthread 的高级特性。

  • 第三个是指向我们想要运行的函数的指针

  • 第四个是将赋予我们的函数的指针

void *(*start_routine) (void *) 这个参数很难理解!它表示一个接受 void * 指针并返回 void * 指针的指针。它看起来像一个函数声明,只是函数的名称被 (* .... ) 包裹起来。

以下是最简单的例子:

#include <stdio.h>
#include <pthread.h>
// remember to set compilation option -pthread

void *busy(void *ptr) {
// ptr will point to "Hi"
    puts("Hello World");
    return NULL;
}
int main() {
    pthread_t id;
    pthread_create(&id, NULL, busy, "Hi");
    while (1) {} // Loop forever
}

如果我们想要等待线程完成,可以使用 pthread_join

void *result;
pthread_join(id, &result);

在上面的例子中,result 将会是 null,因为忙碌的函数返回了 null。我们需要传递结果的地址,因为 pthread_join 将会写入指针的内容。

参见Pthreads Part 2

Pthreads,第二部分:实际应用

更多的 pthread 函数

如何创建一个 pthread?

参见Pthreads Part 1,介绍了 pthread_createpthread_join

如果我调用 pthread_create 两次,我的进程会有多少个堆栈?

你的进程将包含三个堆栈 - 每个线程一个。第一个线程在进程启动时创建,然后你创建了另外两个。实际上可能会有更多的堆栈,但现在让我们忽略这个复杂性。重要的想法是每个线程都需要一个堆栈,因为堆栈包含自动变量和旧的 CPU PC 寄存器,以便在函数完成后可以返回执行调用函数。

一个完整进程和一个线程之间的区别是什么?

此外,与进程不同,同一进程中的线程可以共享相同的全局内存(数据和堆段)。

pthread_cancel 是做什么的?

停止一个线程。请注意,线程可能不会立即停止。例如,当线程进行操作系统调用(例如 write)时,它可以被终止。

在实践中,pthread_cancel 很少被使用,因为它不给线程一个机会在自身之后进行清理(例如,它可能已经打开了一些文件)。另一种实现方法是使用一个布尔(int)变量,其值用于通知其他线程它们应该完成并进行清理。

exitpthread_exit 之间有什么区别?

exit(42) 退出整个进程并设置进程的退出值。这相当于在主方法中返回 42。进程内的所有线程都会停止。

pthread_exit(void *) 只会停止调用线程,即调用 pthread_exit 后线程永远不会返回。如果没有其他线程在运行,pthread 库将自动完成进程。pthread_exit(...) 等同于从线程函数返回;两者都会完成线程,并为线程设置返回值(void *指针)。

main 线程中调用 pthread_exit 是简单程序确保所有线程完成的常见方法。例如,在下面的程序中,myfunc 线程可能没有时间开始。

int main() {
  pthread_t tid1, tid2;
  pthread_create(&tid1, NULL, myfunc, "Jabberwocky");
  pthread_create(&tid2, NULL, myfunc, "Vorpel");
  exit(42); //or return 42;

  // No code is run after exit
}

接下来的两个程序将等待新线程完成-

int main() {
  pthread_t tid1, tid2;
  pthread_create(&tid1, NULL, myfunc, "Jabberwocky");
  pthread_create(&tid2, NULL, myfunc, "Vorpel");
  pthread_exit(NULL); 

  // No code is run after pthread_exit
  // However process will continue to exist until both threads have finished
}

或者,我们可以在每个线程上进行连接(即等待它完成),然后从主函数返回(或调用 exit)。

int main() {
  pthread_t tid1, tid2;
  pthread_create(&tid1, NULL, myfunc, "Jabberwocky");
  pthread_create(&tid2, NULL, myfunc, "Vorpel");
  // wait for both threads to finish :
  void* result;
  pthread_join(tid1, &result);
  pthread_join(tid2, &result); 
  return 42;
}

请注意,pthread_exit 版本会创建线程僵尸,但这不是长时间运行的进程,所以我们不在乎。

线程如何被终止?

  • 从线程函数返回

  • 调用 pthread_exit

  • 使用 pthread_cancel 取消线程

  • 终止进程(例如 SIGTERM);exit();从 main 返回

pthread_join 的目的是什么?

  • 等待线程完成

  • 清理线程资源

  • 获取线程的返回值

如果不调用 pthread_join 会发生什么?

已完成的线程将继续消耗资源。最终,如果创建了足够多的线程,pthread_create 将失败。在实践中,这只是长时间运行进程的问题,但对于简单的短暂进程来说并不是问题,因为当进程退出时,所有线程资源都会被自动释放。

我应该使用 pthread_exit 还是 pthread_join

pthread_exitpthread_join 都会让其他线程自行完成(即使在主线程中调用)。但是,只有 pthread_join 会在指定线程完成时返回。pthread_exit 不会等待,它会立即结束线程,并且不会给你继续执行的机会。

你能把指针从一个线程传递给另一个线程的堆栈变量吗?

是的。但是你需要非常小心关于堆栈变量的生命周期。

pthread_t start_threads() {
  int start = 42;
  pthread_t tid;
  pthread_create(&tid, 0, myfunc, &start); // ERROR!
  return tid;
} 

上面的代码是无效的,因为函数start_threads很可能会在myfunc开始之前返回。该函数传递了start的地址,但是当myfunc执行时,start已经不在作用域内,其地址将被重新用于另一个变量。

以下代码是有效的,因为栈变量的生命周期比后台线程长。

void start_threads() {
  int start = 42;
  void *result;
  pthread_t tid;
  pthread_create(&tid, 0, myfunc, &start); // OK - start will be valid!
  pthread_join(tid, &result);
} 

竞争条件简介

我怎样才能创建十个具有不同起始值的线程。

以下代码应该启动十个值为 0,1,2,3,...9 的线程,但运行时打印出1 7 8 8 8 8 8 8 8 10!你能看出为什么吗?

#include <pthread.h>
void* myfunc(void* ptr) {
    int i = *((int *) ptr);
    printf("%d ", i);
    return NULL;
}

int main() {
    // Each thread gets a different value of i to process
    int i;
    pthread_t tid;
    for(i =0; i < 10; i++) {
        pthread_create(&tid, NULL, myfunc, &i); // ERROR
    }
    pthread_exit(NULL);
}

上面的代码存在“竞争条件” - i 的值正在改变。新线程稍后启动(在示例输出中,最后一个线程在循环结束后启动)。

为了克服这种竞争条件,我们将为每个线程提供一个指向其自己数据区域的指针。例如,对于每个线程,我们可能希望存储 id、起始值和输出值:

struct T {
  pthread_t id;
  int start;
  char result[100];
};

这些可以存储在数组中 -

struct T *info = calloc(10 , sizeof(struct T)); // reserve enough bytes for ten T structures 

并且每个数组元素都传递给每个线程 -

pthread_create(&info[i].id, NULL, func, &info[i]); 

为什么有些函数(例如 asctime、getenv、strtok、strerror)不是线程安全的?

为了回答这个问题,让我们看一个简单的函数,它也不是“线程安全”的

char *to_message(int num) {
    char static result [256];
    if (num < 10) sprintf(result, "%d : blah blah" , num);
    else strcpy(result, "Unknown");
    return result;
}

在上面的代码中,结果缓冲区存储在全局内存中。这很好 - 我们不希望返回指向栈上无效地址的指针,但整个内存中只有一个结果缓冲区。如果两个线程同时使用它,那么一个线程将破坏另一个:

时间线程 1线程 2注释
1to_m(5)
2to_m(99)现在两个线程都会看到结果缓冲区中存储的是“未知”

什么是条件变量、信号量、互斥锁?

这些是同步锁,用于防止竞争条件,并确保同一程序中运行的线程之间的正确同步。此外,这些锁在概念上与内核内部使用的原语相同。

使用线程而不是分叉进程有什么优势吗?

是的!在线程之间共享信息很容易,因为线程(同一进程的线程)存在于相同的虚拟内存空间中。此外,创建线程比创建(分叉)进程要快得多。

使用线程而不是分叉进程有什么缺点吗?

是的!没有隔离!因为线程存在于同一个进程中,一个线程可以访问与其他线程相同的虚拟内存。一个线程可以终止整个进程(例如,尝试读取地址零)。

您可以使用多个线程分叉一个进程吗?

是的!但是子进程只有一个线程(这是调用fork的线程的克隆)。我们可以将其视为一个简单的例子,后台线程在子进程中从不打印出第二条消息。

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

static pid_t child = -2;

void *sleepnprint(void *arg) {
  printf("%d:%s starting up...\n", getpid(), (char *) arg);

  while (child == -2) {sleep(1);} /* Later we will use condition variables */

  printf("%d:%s finishing...\n",getpid(), (char*)arg);

  return NULL;  
}
int main() {
  pthread_t tid1, tid2;
  pthread_create(&tid1,NULL, sleepnprint, "New Thread One");
  pthread_create(&tid2,NULL, sleepnprint, "New Thread Two");

  child = fork();
  printf("%d:%s\n",getpid(), "fork()ing complete");
  sleep(3);

  printf("%d:%s\n",getpid(), "Main thread finished");

  pthread_exit(NULL);
  return 0; /* Never executes */
}
8970:New Thread One starting up...
8970:fork()ing complete
8973:fork()ing complete
8970:New Thread Two starting up...
8970:New Thread Two finishing...
8970:New Thread One finishing...
8970:Main thread finished
8973:Main thread finished 

实际上,在分叉之前创建线程可能会导致意外错误,因为(如上所示)其他线程在分叉时立即终止。另一个线程可能刚刚锁定了互斥锁(例如通过调用 malloc),并且再也不会解锁。高级用户可能会发现pthread_atfork有用,但我们建议您通常尽量避免在分叉之前创建线程,除非您完全了解这种方法的限制和困难。

还有其他情况下fork可能比创建线程更可取吗。

创建单独的进程很有用

  • 当需要更多安全性时(例如,Chrome 浏览器为不同的标签使用不同的进程)

  • 在运行现有和完整的程序时,需要一个新进程(例如,启动'gcc')

  • 当您遇到同步原语并且每个进程都在系统中操作某些东西时

我怎样才能找到更多信息?

man 页面中查看完整示例,并在pthread 参考指南中查看。另外:简明的第三方示例代码,解释创建、连接和退出

Pthreads,第三部分:并行问题(奖励)

概述

下一节将讨论当 pthread 发生冲突时会发生什么,但如果每个线程做的事情完全不同,没有重叠呢?

我们找到了最大加速并行问题吗?

尴尬的并行问题

并行算法的研究在过去几年里迅速发展。一个尴尬的并行问题是指需要很少的工作就可以转换为并行的问题。其中很多问题都涉及一些同步概念,但并非总是如此。你已经知道一个可并行化的算法,归并排序!

void merge_sort(int *arr, size_t len){
     if(len > 1){
     //Mergesort the left half
     //Mergesort the right half
     //Merge the two halves
     }

有了对线程的新理解,你只需要为左半部分创建一个线程,为右半部分创建一个线程。鉴于你的 CPU 有多个真实核心,你将看到与Amdahl's Law相符的加速。时间复杂度分析在这里也变得有趣。并行算法的运行时间为 O(log^3(n))(因为我们假设有很多核心)。

然而在实践中,我们通常会做两个改变。一是,一旦数组变得足够小,我们就会放弃并行归并排序算法,转而使用快速排序或其他在小数组上运行快速的算法(某种缓存一致性)。另一件我们知道的事情是,CPU 并不拥有无限的核心。为了解决这个问题,我们通常会保留一个工作池。

工作池

我们知道 CPU 的核心数量是有限的。很多时候我们会启动一些线程,并在它们空闲时给它们任务。

另一个问题,Parallel Map

假设我们想要对整个数组应用一个函数,一次处理一个元素。

int *map(int (*func)(int), int *arr, size_t len){
    int *ret = malloc(len*sizeof(*arr));
    for(size_t i = 0; i < len; ++i) 
        ret[i] = func(arr[i]);
    return ret;
}

由于没有任何元素依赖于其他元素,你会如何并行化这个问题?你认为在线程之间如何分配工作最好?

调度

有几种方法可以分解工作。

  • 静态调度:将问题分解成固定大小的块(预先确定的),并让每个线程处理其中的每个块。当每个子问题花费的时间大致相同时,这种方法效果很好,因为没有额外的开销。你只需要编写一个循环,并将 map 函数分配给每个子数组。

  • 动态调度:当一个新问题可用时,让一个线程处理它。当你不知道调度需要多长时间时,这是很有用的。

  • 引导调度:这是上述两种方法的混合,具有各自的优点和权衡。你可以从静态调度开始,如果需要的话慢慢转向动态调度。

  • 运行时调度:你完全不知道问题需要多长时间。与其自己决定,不如让程序决定该做什么!

来源,但不需要记住。

一些缺点

你不会立即看到加速,因为缓存一致性和调度额外的线程等原因。

其他问题

Wikipedia

  • 在 Web 服务器上为多个用户提供静态文件。

  • 曼德勃罗集、Perlin 噪声和类似的图像,其中每个点都是独立计算的。

  • 计算机图形的渲染。在计算机动画中,每一帧可能是独立渲染的(参见并行渲染)。

  • 在密码学中的暴力搜索。值得注意的现实世界例子包括 distributed.net 和加密货币中使用的工作证明系统。

  • 生物信息学中用于多个查询的 BLAST 搜索(但不适用于单个大查询)[9]

  • 大规模人脸识别系统将数千个任意获取的人脸(例如,通过闭路电视的安全或监控视频)与同样大量的先前存储的人脸(例如,罪犯库或类似的观察名单)进行比较。

  • 比较许多独立场景的计算机模拟,例如气候模型。

  • 进化计算元启发式,如遗传算法。

  • 数值天气预报的集合计算。

  • 粒子物理中的事件模拟和重建。

  • Marching squares 算法

  • 二次筛和数域筛的筛选步骤。

  • 随机森林机器学习技术中的树生长步骤。

  • 离散傅立叶变换,其中每个谐波都是独立计算的。

Pthread 复习问题

主题

  • pthread 生命周期

  • 每个线程都有一个堆栈

  • 从线程中捕获返回值

  • 使用pthread_join

  • 使用pthread_create

  • 使用pthread_exit

  • 在什么条件下进程会退出

问题

  • 当创建一个 pthread 时会发生什么?(你不需要进入超级细节)

  • 每个线程的堆栈在哪里?

  • 如何在给定pthread_t的情况下获得返回值?线程可以如何设置返回值?如果丢弃返回值会发生什么?

  • 为什么pthread_join很重要(考虑堆栈空间、寄存器、返回值)?

  • 在正常情况下pthread_exit做什么(即你不是最后一个线程)?调用 pthread_exit 时会调用哪些其他函数?

  • 给我三个多线程进程将退出的条件。你还能想到其他条件吗?

  • 什么是尴尬并行问题?

五、同步

同步,第一部分:互斥锁

解决临界区

什么是临界区?

临界区是一段代码,只能由一个线程同时执行,如果程序要正确运行。如果两个线程(或进程)同时在临界区内执行代码,那么可能程序可能不再具有正确的行为。

仅仅递增一个变量是否是临界区?

可能。递增变量(i++)是通过三个单独的步骤执行的:将内存内容复制到 CPU 寄存器。增加 CPU 中的值。将新值存储在内存中。如果内存位置只能由一个线程访问(例如下面的自动变量i),则不可能发生竞争条件,也没有与i相关的临界区。但是,sum变量是全局变量,并且被两个线程访问。可能两个线程可能同时尝试递增变量。

#include <stdio.h>
#include <pthread.h>
// Compile with -pthread

int sum = 0; //shared

void *countgold(void *param) {
    int i; //local to each thread
    for (i = 0; i < 10000000; i++) {
        sum += 1;
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, countgold, NULL);
    pthread_create(&tid2, NULL, countgold, NULL);

    //Wait for both threads to finish:
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("ARRRRG sum is %d\n", sum);
    return 0;
}

上述代码的典型输出是ARGGGH sum is 8140268每次运行程序时都会打印不同的总和,因为存在竞争条件;代码无法阻止两个线程同时读写sum。例如,两个线程都将当前的 sum 值复制到运行每个线程的 CPU 中(假设为 123)。两个线程都将其自己的副本增加一。两个线程写回该值(124)。如果线程在不同时间访问了 sum,则计数将为 125。

如何确保一次只有一个线程可以访问全局变量?

你的意思是,“帮助 - 我需要一个互斥体!”如果一个线程当前正在临界区内,我们希望另一个线程等到第一个线程完成。为此,我们可以使用互斥体(Mutual Exclusion 的缩写)。

对于简单的示例,我们需要添加的代码最少只有三行:

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER; // global variable
pthread_mutex_lock(&m); // start of Critical Section
pthread_mutex_unlock(&m); //end of Critical Section

一旦我们完成了互斥体,我们还应该调用pthread_mutex_destroy(&m)。请注意,您只能销毁未锁定的互斥体。对已销毁的锁调用 destroy,初始化已初始化的锁,锁定已锁定的锁,解锁未锁定的锁等都是不受支持的(至少对于默认的互斥体),通常会导致未定义的行为。

如果我锁定了互斥体,是否会阻止所有其他线程?

不,其他线程将继续。只有当一个线程尝试锁定已经锁定的互斥体时,线程才必须等待。一旦原始线程解锁互斥体,第二个(等待的)线程将获取锁并能够继续。

还有其他创建互斥体的方法吗?

可以。您可以仅对全局(“静态”)变量使用宏 PTHREAD_MUTEX_INITIALIZER。m = PTHREAD_MUTEX_INITIALIZER 等同于更通用的pthread_mutex_init(&m,NULL)。init 版本包括用于在性能和额外错误检查以及高级共享选项之间进行权衡的选项。

pthread_mutex_t *lock = malloc(sizeof(pthread_mutex_t)); 
pthread_mutex_init(lock, NULL);
//later
pthread_mutex_destroy(lock);
free(lock);

关于“init”和“destroy”需要记住的事情:

  • 多个线程的初始化/销毁具有未定义的行为

  • 销毁锁定的互斥体具有未定义的行为

  • 基本上尝试遵循一个线程初始化一个互斥体,而且只有一个线程初始化一个互斥体的模式。

互斥体陷阱

所以 pthread_mutex_lock在其他线程读取相同变量时会停止吗?

不,互斥体不是那么聪明 - 它与代码(线程)一起工作,而不是数据。只有当另一个线程在锁定的互斥体上调用lock时,第二个线程才需要等待,直到互斥体被解锁。

考虑

int a;
pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER,
                 m2 = = PTHREAD_MUTEX_INITIALIZER;
// later
// Thread 1
pthread_mutex_lock(&m1);
a++;
pthread_mutex_unlock(&m1);

// Thread 2
pthread_mutex_lock(&m2);
a++;
pthread_mutex_unlock(&m2);

仍会导致竞争条件。

我可以在 fork 之前创建互斥体吗?

是的 - 但是子进程和父进程将不共享虚拟内存,并且每个进程都将拥有独立于其他进程的互斥体。

(高级说明:使用共享内存有高级选项,允许子进程和父进程共享互斥体,如果使用正确的选项并使用共享内存段。请参阅stackoverflow 示例

如果一个线程锁定了一个互斥锁,另一个线程能解锁它吗?

不行。同一个线程必须解锁它。

我可以使用两个或更多的互斥锁吗?

是的!事实上,通常每个需要更新的数据结构都有一个锁。

如果你只有一个锁,那么两个线程之间可能会对锁有显著的争用,这是不必要的。例如,如果两个线程正在更新两个不同的计数器,可能不需要使用相同的锁。

然而,简单地创建许多锁是不够的:重要的是能够推理关于临界区的问题,例如,一个线程不能在更新期间读取两个数据结构,而这两个数据结构暂时处于不一致的状态。

调用 lock 和 unlock 会有任何开销吗?

调用pthread_mutex_lock_unlock会有一些开销;然而这是你为了程序正确运行所付出的代价!

最简单的完整示例?

下面显示了一个完整的示例

#include <stdio.h>
#include <pthread.h>

// Compile with -pthread
// Create a mutex this ready to be locked!
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

int sum = 0;

void *countgold(void *param) {
    int i;

    //Same thread that locks the mutex must unlock it
    //Critical section is just 'sum += 1'
    //However locking and unlocking a million times
    //has significant overhead in this simple answer

    pthread_mutex_lock(&m);

    // Other threads that call lock will have to wait until we call unlock

    for (i = 0; i < 10000000; i++) {
    sum += 1;
    }
    pthread_mutex_unlock(&m);
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, countgold, NULL);
    pthread_create(&tid2, NULL, countgold, NULL);

    //Wait for both threads to finish:
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("ARRRRG sum is %d\n", sum);
    return 0;
}

在上面的代码中,线程在进入计数室之前获取了锁。关键部分只有sum+=1,所以下一个版本也是正确的但更慢 -

    for (i = 0; i < 10000000; i++) {
        pthread_mutex_lock(&m);
        sum += 1;
        pthread_mutex_unlock(&m);
    }
    return NULL;
}

这个过程运行得更慢,因为我们一百万次锁定和解锁互斥锁,这是昂贵的 - 至少与递增一个变量相比是昂贵的。(在这个简单的例子中,我们并不真正需要线程 - 我们可以加两次!)一个更快的多线程示例是使用一个自动(本地)变量添加一百万,然后在计算循环结束后将其添加到共享总数中:

    int local = 0;
    for (i = 0; i < 10000000; i++) {
       local += 1;
    }

    pthread_mutex_lock(&m);
    sum += local;
    pthread_mutex_unlock(&m);

    return NULL;
}

如果我忘记解锁会发生什么?

死锁!我们稍后会谈论死锁,但如果多个线程调用这个循环会有什么问题。

while(not_stop){
    //stdin may not be thread safe
    pthread_mutex_lock(&m);
    char *line = getline(...);
    if(rand() % 2) { /* randomly skip lines */
         continue;
    }
    pthread_mutex_unlock(&m);

    process_line(line);
}

我什么时候可以销毁互斥锁?

你只能销毁一个未锁定的互斥锁

我可以将 pthread_mutex_t 复制到新的内存位置吗?

不行,将互斥锁的字节复制到新的内存位置,然后使用副本是支持的。

互斥锁的简单实现会是什么样的?

下面显示了一个简单(但不正确!)的建议。unlock函数只是解锁互斥锁并返回。lock 函数首先检查锁是否已经被锁定。如果当前已经被锁定,它将继续检查,直到另一个线程解锁互斥锁。

// Version 1 (Incorrect!)

void lock(mutex_t *m) {
  while(m->locked) { /*Locked? Nevermind - just loop and check again!*/ }

  m->locked = 1;
}
void unlock(mutex_t *m) {
  m->locked = 0;
}

版本 1 使用了“忙等待”(不必要地浪费 CPU 资源),但更严重的问题是:我们有一个竞争条件!

如果两个线程同时调用lock,有可能两个线程都会将'm_locked'读取为零。因此,两个线程都会认为它们对锁有独占访问权,然后两个线程都会继续。哎呀!

我们可以尝试通过在循环内调用pthread_yield()来减少一点 CPU 开销 - pthread_yield 建议操作系统暂时不使用 CPU,因此 CPU 可能被分配给等待运行的线程。但这并不能解决竞争条件。我们需要一个更好的实现 - 你能想出如何防止竞争条件吗?

我怎样才能了解更多?

玩! 阅读 man page!

同步,第二部分:计数信号量

什么是计数信号量?

计数信号量包含一个值,并支持两个操作“等待”和“发布”。发布增加信号量并立即返回。“等待”将在计数为零时等待。如果计数不为零,则信号量将减少计数并立即返回。

一个类比是饼干罐中的饼干数量(或者宝箱中的金币数量)。在拿饼干之前,调用“等待”。如果没有剩下饼干,那么等待将不会返回:它将等待,直到另一个线程通过调用 post 增加信号量。

简而言之,“发布”增加并立即返回,而“等待”将在计数为零时等待。在返回之前,它将减少计数。

我如何创建一个信号量?

本页介绍了未命名信号量。不幸的是,Mac OS X 目前还不支持这些。

首先决定初始值是零还是其他值(例如数组中剩余空间的数量)。与 pthread 互斥锁不同,创建信号量没有捷径 - 使用sem_init

#include <semaphore.h>

sem_t s;
int main() {
  sem_init(&s, 0, 10); // returns -1 (=FAILED) on OS X
  sem_wait(&s); // Could do this 10 times without blocking
  sem_post(&s); // Announce that we've finished (and one more resource item is available; increment count)
  sem_destroy(&s); // release resources of the semaphore
}

我可以从不同的线程调用 wait 和 post 吗?

可以!与互斥锁不同,增量和减量可以来自不同的线程。

可以使用信号量代替互斥锁吗?

是的 - 虽然信号量的开销更大。要使用信号量:

  • 用计数为一初始化信号量。

  • ...lock替换sem_wait

  • ...unlock替换sem_post

互斥锁是一个在“发布”之前始终“等待”的信号量

sem_t s;
sem_init(&s, 0, 1);

sem_wait(&s);
// Critical Section
sem_post(&s);

我可以在信号处理程序中使用 sem_post 吗?

是的!sem_post是少数几个可以在信号处理程序中正确使用的函数之一。这意味着我们可以释放一个等待的线程,该线程现在可以进行所有我们不允许在信号处理程序本身内调用的调用(例如printf)。

#include <stdio.h>
#include <pthread.h>
#include <signal.h>
#include <semaphore.h>
#include <unistd.h>

sem_t s;

void handler(int signal)
{
    sem_post(&s); /* Release the Kraken! */
}

void *singsong(void *param)
{
    sem_wait(&s);
    printf("I had to wait until your signal released me!\n");
}

int main()
{
    int ok = sem_init(&s, 0, 0 /* Initial value of zero*/); 
    if (ok == -1) {
       perror("Could not create unnamed semaphore");
       return 1;
    }
    signal(SIGINT, handler); // Too simple! See note below

    pthread_t tid;
    pthread_create(&tid, NULL, singsong, NULL);
    pthread_exit(NULL); /* Process will exit when there are no more threads */
}

请注意,健壮的程序不会在多线程程序中使用signal()(“在多线程进程中使用 signal()的效果是未指定的。”- 信号手册页);一个更正确的程序将需要使用sigaction

我如何找到更多信息?

阅读手册页:

同步,第三部分:使用互斥锁和信号量

线程安全的堆栈

什么是原子操作?

用维基百科的话来说,

如果一个操作(或一组操作)在系统的其他部分看起来是瞬间发生的,那么它就是原子的或不可中断的。没有锁,只有简单的 CPU 指令(“从内存中读取这个字节”)是原子的(不可分割的)。在单 CPU 系统中,可以暂时禁用中断(这样一系列操作就不能被中断),但实际上原子性是通过使用同步原语来实现的,通常是互斥锁。

递增变量(i++是原子的,因为它需要三个不同的步骤:将位模式从内存复制到 CPU;使用 CPU 的寄存器进行计算;将位模式复制回内存。在这个递增序列期间,另一个线程或进程仍然可以读取旧值,并且当递增序列完成时,对同一内存的其他写入也会被覆盖。

我如何使用互斥锁使我的数据结构线程安全?

请注意,这只是一个介绍 - 编写高性能的线程安全数据结构需要自己的书!这是一个简单的数据结构(堆栈),它不是线程安全的:

// A simple fixed-sized stack (version 1)
#define STACK_SIZE 20
int count;
double values[STACK_SIZE];

void push(double v) { 
    values[count++] = v; 
}

double pop() {
    return values[--count];
}

int is_empty() {
    return count == 0;
}

堆栈的版本 1 不是线程安全的,因为如果两个线程同时调用 push 或 pop,那么结果或堆栈可能是不一致的。例如,想象一下,如果两个线程同时调用 pop,那么两个线程可能读取相同的值,两个线程可能读取原始计数值。

要将其转换为线程安全的数据结构,我们需要确定我们代码的关键部分,即哪些部分的代码必须一次只有一个线程。在上面的例子中,pushpopis_empty函数访问相同的变量(即内存),并且堆栈的所有关键部分。

push(和pop)正在执行时,数据结构处于不一致状态(例如计数可能尚未写入,因此可能仍然包含原始值)。通过用互斥锁包装这些方法,我们可以确保一次只有一个线程可以更新(或读取)堆栈。

以下是一个候选的“解决方案”。它正确吗?如果不是,它将如何失败?

// An attempt at a thread-safe stack (version 2)
#define STACK_SIZE 20
int count;
double values[STACK_SIZE];

pthread_mutex_t m1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t m2 = PTHREAD_MUTEX_INITIALIZER;

void push(double v) { 
    pthread_mutex_lock(&m1);
    values[count++] = v;
    pthread_mutex_unlock(&m1);
}

double pop() {
    pthread_mutex_lock(&m2);
    double v = values[--count];
    pthread_mutex_unlock(&m2);

    return v;
}

int is_empty() {
    pthread_mutex_lock(&m1);
    return count == 0;
    pthread_mutex_unlock(&m1);
}

上面的代码(“版本 2”)至少包含一个错误。花点时间看看你能不能找到错误,并弄清楚后果。

如果三个线程同时调用push(),锁m1确保只有一个线程在操作堆栈(两个线程将需要等待,直到第一个线程完成(调用解锁),然后第二个线程将被允许继续进入临界区,最后第三个线程将在第二个线程完成后被允许继续)。

类似的论点也适用于并发调用(同时调用)pop。然而,版本 2 不会阻止pushpop同时运行,因为pushpop使用两个不同的互斥锁。

在这种情况下,修复很简单 - 对 push 和 pop 函数都使用相同的互斥锁。

代码还有第二个错误;is_empty在比较后返回,不会解锁互斥锁。然而,错误不会立即被发现。例如,假设一个线程调用is_empty,稍后第二个线程调用push。这个线程会神秘地停止。使用调试器,你可以发现线程在push方法内的 lock()方法处被卡住,因为之前的is_empty调用没有解锁。因此,一个线程的疏忽导致了任意其他线程在以后的时间出现问题。

以下是更好的版本 -

// An attempt at a thread-safe stack (version 3)
int count;
double values[count];
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void push(double v) { 
  pthread_mutex_lock(&m); 
  values[count++] = v;
  pthread_mutex_unlock(&m);
}
double pop() {
  pthread_mutex_lock(&m);
  double v = values[--count];
  pthread_mutex_unlock(&m);
  return v;
}
int is_empty() {
  pthread_mutex_lock(&m);
  int result= count == 0;
  pthread_mutex_unlock(&m);
  return result;
}

版本 3 是线程安全的(我们已经确保了所有关键部分的互斥),但有两点需要注意:

  • is_empty是线程安全的,但它的结果可能已经过时,即在线程得到结果时,堆栈可能不再为空!

  • 没有保护免受下溢(在空堆栈上弹出)或上溢(在已满堆栈上推入)

后一点可以使用计数信号量来修复。

该实现假定为单个堆栈。更通用的版本可能会将互斥锁作为内存结构的一部分,并使用 pthread_mutex_init 来初始化互斥锁。例如,

// Support for multiple stacks (each one has a mutex)
typedef struct stack {
  int count;
  pthread_mutex_t m; 
  double *values;
} stack_t;

stack_t* stack_create(int capacity) {
  stack_t *result = malloc(sizeof(stack_t));
  result->count = 0;
  result->values = malloc(sizeof(double) * capacity);
  pthread_mutex_init(&result->m, NULL);
  return result;
}
void stack_destroy(stack_t *s) {
  free(s->values);
  pthread_mutex_destroy(&s->m);
  free(s);
}
// Warning no underflow or overflow checks!

void push(stack_t *s, double v) { 
  pthread_mutex_lock(&s->m); 
  s->values[(s->count)++] = v; 
  pthread_mutex_unlock(&s->m); }

double pop(stack_t *s) { 
  pthread_mutex_lock(&s->m); 
  double v = s->values[--(s->count)]; 
  pthread_mutex_unlock(&s->m); 
  return v;
}

int is_empty(stack_t *s) { 
  pthread_mutex_lock(&s->m); 
  int result = s->count == 0; 
  pthread_mutex_unlock(&s->m);
  return result;
}

示例用法:

int main() {
    stack_t *s1 = stack_create(10 /* Max capacity*/);
    stack_t *s2 = stack_create(10);
    push(s1, 3.141);
    push(s2, pop(s1));
    stack_destroy(s2);
    stack_destroy(s1);
}

堆栈信号量

如果堆栈为空或已满,我如何强制我的线程等待?

使用计数信号量!使用计数信号量来跟踪剩余空间的数量,另一个信号量来跟踪堆栈中项目的数量。我们将称这两个信号量为'sremain'和'sitems'。记住,sem_wait会在信号量的计数被另一个线程调用sem_post减少到零时等待。

// Sketch #1

sem_t sitems;
sem_t sremain;
void stack_init(){
  sem_init(&sitems, 0, 0);
  sem_init(&sremain, 0, 10);
}

double pop() {
  // Wait until there's at least one item
  sem_wait(&sitems);
  ...

void push(double v) {
  // Wait until there's at least one space
  sem_wait(&sremain);
  ...

草图#2 已经实现了太早的post。在 push 中等待的另一个线程可能会错误地尝试写入一个已满的堆栈(同样,等待 pop()的线程可能会过早地继续)。

// Sketch #2 (Error!)
double pop() {
  // Wait until there's at least one item
  sem_wait(&sitems);
  sem_post(&sremain); // error! wakes up pushing() thread too early
  return values[--count];
}
void push(double v) {
  // Wait until there's at least one space
  sem_wait(&sremain);
  sem_post(&sitems); // error! wakes up a popping() thread too early
  values[count++] = v;
}

草图 3 实现了正确的信号量逻辑,但你能发现错误吗?

// Sketch #3 (Error!)
double pop() {
  // Wait until there's at least one item
  sem_wait(&sitems);
  double v= values[--count];
  sem_post(&sremain);
  return v;
}

void push(double v) {
  // Wait until there's at least one space
  sem_wait(&sremain);
  values[count++] = v;
  sem_post(&sitems); 
}

草图 3 正确地使用信号量强制执行了缓冲区满和缓冲区空的条件。然而,没有互斥:两个线程可以同时处于临界区,这将破坏数据结构(或至少导致数据丢失)。修复方法是在临界区周围包装一个互斥锁:

// Simple single stack - see above example on how to convert this into a multiple stacks.
// Also a robust POSIX implementation would check for EINTR and error codes of sem_wait.

// PTHREAD_MUTEX_INITIALIZER for statics (use pthread_mutex_init() for stack/heap memory)

pthread_mutex_t m= PTHREAD_MUTEX_INITIALIZER; 
int count = 0;
double values[10];
sem_t sitems, sremain;

void init() {
  sem_init(&sitems, 0, 0);
  sem_init(&sremains, 0, 10); // 10 spaces
}

double pop() {
  // Wait until there's at least one item
  sem_wait(&sitems);

  pthread_mutex_lock(&m); // CRITICAL SECTION
  double v= values[--count];
  pthread_mutex_unlock(&m);

  sem_post(&sremain); // Hey world, there's at least one space
  return v;
}

void push(double v) {
  // Wait until there's at least one space
  sem_wait(&sremain);

  pthread_mutex_lock(&m); // CRITICAL SECTION
  values[count++] = v;
  pthread_mutex_unlock(&m);

  sem_post(&sitems); // Hey world, there's at least one item
}
// Note a robust solution will need to check sem_wait's result for EINTR (more about this later)

常见的互斥锁陷阱是什么?

  • 由于愚蠢的拼写错误而锁定/解锁错误的互斥锁

  • 未解锁互斥锁(由于在错误条件下提前返回)

  • 资源泄漏(未调用pthread_mutex_destroy

  • 使用未初始化的互斥锁(或使用已被销毁的互斥锁)

  • 在线程上两次锁定互斥锁(未首先解锁)

  • 死锁和优先级反转(我们稍后会讨论这些)

同步,第四部分:关键部分问题

候选解决方案

什么是关键部分问题?

如已在Synchronization, Part 3: Working with Mutexes And Semaphores中讨论的,我们的代码中有一些关键部分只能由一个线程同时执行。我们将这种要求描述为“互斥排他”;只有一个线程(或进程)可以访问共享资源。

在多线程程序中,我们可以使用互斥锁和解锁调用来包装关键部分:

pthread_mutex_lock() - one thread allowed at a time! (others will have to wait here)
... Do Critical Section stuff here!
pthread_mutex_unlock() - let other waiting threads continue

我们如何实现这些锁定和解锁调用?我们能创建一个保证互斥的算法吗?下面显示了一个不正确的实现,

pthread_mutex_lock(p_mutex_t *m)     { while(m->lock) {}; m->lock = 1;}
pthread_mutex_unlock(p_mutex_t *m)   { m->lock = 0; }

乍一看,代码似乎是有效的;如果一个线程尝试锁定互斥量,稍后的线程必须等到锁被释放。然而,这种实现不能满足互斥。让我们从两个大致同时运行的线程的角度仔细观察这个“实现”。在下表中,时间从上到下依次进行-

时间线程 1线程 2
1while(lock) {}
2while(lock) {}
3lock = 1lock = 1

哎呀!存在竞争条件。不幸的是,两个线程都检查了锁并读取了一个错误的值,因此能够继续执行。

关键部分问题的候选解决方案。

为了简化讨论,我们只考虑两个线程。请注意,这些论点适用于线程和进程,经典的 CS 文献讨论了这些问题,涉及到需要对关键部分或共享资源进行独占访问(即互斥)的两个进程。

提高标志表示线程/进程进入关键部分的意图。

请记住,下面概述的伪代码是较大程序的一部分;线程或进程通常需要在进程的生命周期中多次进入关键部分。因此,想象每个示例都包裹在一个循环中,在循环中线程或进程在其他事务上工作了一段随机时间。

下面描述的候选解决方案有什么问题吗?

// Candidate #1
wait until your flag is lowered
raise my flag
// Do Critical Section stuff
lower my flag 

答案:候选解决方案#1 也存在竞争条件,即它不能满足互斥排他,因为两个线程/进程都可以读取对方的标志值(=降低)并继续。

这表明我们应该在检查其他线程的标志之前提高标志 - 这是下面的候选解决方案#2。

// Candidate #2
raise my flag
wait until your flag is lowered
// Do Critical Section stuff
lower my flag 

候选方案#2 满足互斥 - 不可能同时有两个线程在关键部分内。然而,这段代码存在死锁问题!假设两个线程希望同时进入关键部分:

时间线程 1线程 2
1raise my flag
2raise my flag

| 3 | wait... | wait... |

哎呀,现在两个线程/进程都在等待对方降低他们的标志。现在两者都将永远无法进入关键部分!

这表明我们应该使用轮流变量来尝试解决谁应该继续的问题。

轮流解决方案

以下候选解决方案#3 使用轮流变量礼貌地允许一个线程,然后另一个线程继续

// Candidate #3
wait until my turn is myid
// Do Critical Section stuff
turn = yourid 

候选方案#3 满足互斥(每个线程或进程都可以独占访问关键部分),但是两个线程/进程必须采取严格的轮流方式来使用关键部分;即它们被迫进入交替的关键部分访问模式。例如,如果线程 1 希望每毫秒读取一个哈希表,但另一个线程每秒写入一个哈希表,那么读取线程必须再等待 999 毫秒才能再次从哈希表中读取。这种“解决方案”是不有效的,因为我们的线程应该能够取得进展并在没有其他线程当前在关键部分时进入关键部分。

对关键部分问题的解决方案的期望属性?

在解决关键部分问题中,我们希望的有三个主要的理想属性

  • 互斥 - 线程/进程获得独占访问权;其他线程/进程必须等待,直到它退出关键部分。

  • 有界等待 - 如果线程/进程必须等待,那么它只能等待有限的时间(不允许无限等待时间!)。有界等待的确切定义是,在给定进程进入之前,任何其他进程可以进入其关键部分的次数有一个上限(非无限)。

  • 进度 - 如果没有线程/进程在关键部分内,那么线程/进程应该能够继续进行(取得进展)而无需等待。

在考虑这些想法的基础上,让我们检查另一个候选解决方案,只有在两个线程同时需要访问时才使用基于轮换的标志。

轮换和标志解决方案

以下是 CSP 的正确解决方案吗?

\\ Candidate #4
raise my flag
if your flag is raised, wait until my turn
// Do Critical Section stuff
turn = yourid
lower my flag 

一位教师和另一位 CS 教师最初也是这样认为的!然而,分析这些解决方案是棘手的。甚至关于这个特定主题的同行评审论文中也包含不正确的解决方案!乍一看,它似乎满足互斥、有界等待和进度:基于轮换的标志仅在出现平局时使用(因此允许进度和有界等待),并且似乎满足互斥。然而....也许你可以找到一个反例?

候选#4 失败,因为一个线程不会等到另一个线程降低他们的标志。经过一番思考(或灵感),可以创建以下场景来演示互斥不满足。

想象第一个线程运行这段代码两次(所以轮换标志现在指向第二个线程)。当第一个线程仍然在关键部分内时,第二个线程到达。第二个线程可以立即继续进入关键部分!

时间轮换线程#1线程#2
12raise my flag
22if your flag is raised, wait until my turnraise my flag
32// Do Critical Section stuffif your flag is raised, wait until my turn(真的!)
42// Do Critical Section stuff// Do Critical Section stuff - 糟糕

有效的解决方案

Peterson 的解决方案是什么?

Peterson 在 1981 年的一篇两页论文中发表了他的小说和令人惊讶的简单解决方案。下面显示了他算法的一个版本,使用了共享变量turn

\\ Candidate #5
raise my flag
turn = your_id
wait until your flag is lowered and turn is yourid
// Do Critical Section stuff
lower my flag 

该解决方案满足互斥、有界等待和进度。如果线程#2 将轮换设置为 2 并且当前在关键部分内。线程#1 到达,将轮换设置回 1,现在等待直到线程 2 降低标志。

Peterson 原始文章 pdf 的链接:G. L. Peterson: "关于互斥问题的神话",信息处理通讯 12(3) 1981, 115–116

Peterson 的解决方案是第一个解决方案吗?

不,Dekkers 算法(1962 年)是第一个可以证明正确的解决方案。以下是该算法的一个版本。

raise my flag
while(your flag is raised) :
   if it's your turn to win :
     lower my flag
     wait while your turn
     raise my flag
// Do Critical Section stuff
set your turn to win
lower my flag 

请注意,无论循环迭代零次、一次还是多次,进程的标志在关键部分始终被提升。此外,该标志可以被解释为立即意图进入关键部分。只有在另一个进程也提升了标志时,一个进程才会推迟,降低他们的意图标志并等待。

我可以只在 C 或汇编中实现 Peterson 的(或 Dekkers)算法吗?

是的 - 通过一点搜索,甚至今天也可以在特定简单的移动处理器上找到它的生产应用:Peterson 的算法用于实现 Tegra 移动处理器的低级 Linux 内核锁(由 Nvidia 的系统级芯片 ARM 处理器和 GPU 核心)android.googlesource.com/kernel/tegra.git/+/android-tegra-3.10/arch/arm/mach-tegra/sleep.S#58

然而,一般来说,CPU 和 C 编译器可以重新排序 CPU 指令或使用 CPU 核心特定的本地缓存值,如果另一个核心更新共享变量,则这些值可能是过时的。因此,对于大多数平台来说,简单的伪代码到 C 的实现太天真了。你现在可以停止阅读了。

哦... 你决定继续阅读。好吧,这里有龙!别说我们没警告过你。考虑这是一个高级和棘手的话题,但(剧透)有一个美好的结局。

考虑以下代码,

while(flag2 ) { /* busy loop - go around again */

一个高效的编译器会推断flag2变量在循环内部永远不会改变,因此测试可以优化为while(true)。使用volatile可以在一定程度上防止这种类型的编译器优化。

独立指令可以被优化编译器重新排序,或者在运行时由 CPU 的乱序执行优化重新排序。如果代码需要变量被修改和检查以及精确的顺序,这些复杂的优化。

一个相关的挑战是 CPU 核心包括数据缓存,用于存储最近读取或修改的主内存值。修改后的值可能不会立即写回主内存或重新从内存中读取。因此,数据更改,例如上面示例中的标志和转换变量的状态,可能不会在两个 CPU 核心之间共享。

但是有一个美好的结局。幸运的是,现代硬件使用“内存栅栏”(也称为内存屏障)CPU 指令来解决这些问题,以确保主内存和 CPU 缓存处于合理和一致的状态。更高级别的同步原语,如pthread_mutex_lock,将调用这些 CPU 指令作为其实现的一部分。因此,在实践中,使用互斥锁的临界区周围的锁定和解锁调用足以忽略这些低级问题。

进一步阅读:我们建议阅读以下网帖,讨论在 x86 进程上实现 Peterson 算法以及关于内存屏障的 Linux 文档。

bartoszmilewski.com/2008/11/05/who-ordered-memory-fences-on-an-x86/ lxr.free-electrons.com/source/Documentation/memory-barriers.txt

硬件解决方案

我们如何在硬件上实现临界区问题?

我们可以使用 C11 原子操作来完美地做到这一点!完整的解决方案在这里详细说明(这是一个自旋锁互斥体,futex的实现可以在网上找到)。

typedef struct mutex_{
    atomic_int_least8_t lock;
    pthread_t owner;
} mutex;

#define UNLOCKED 0
#define LOCKED 1
#define UNASSIGNED_OWNER 0

int mutex_init(mutex* mtx){
    if(!mtx){
        return 0;
    }
    atomic_init(&mtx->lock, UNLOCKED); // Not thread safe the user has to take care of this
    mtx->owner = UNASSIGNED_OWNER;
    return 1;
}

这是初始化代码,这里没有什么花哨的。我们将互斥体的状态设置为未锁定,并将所有者设置为已锁定。

int mutex_lock(mutex* mtx){
    int_least8_t zero = UNLOCKED;
    while(!atomic_compare_exchange_weak_explicit
            (&mtx->lock, 
             &zero, 
             LOCKED,
             memory_order_relaxed,
             memory_order_relaxed)){
        zero = UNLOCKED;
        sched_yield(); //Use system calls for scheduling speed
    }
    //We have the lock now!!!!
    mtx->owner = pthread_self();
    return 1;
}

天啊!这段代码是做什么的?首先,它初始化一个变量,我们将保持为未锁定状态。原子比较和交换是大多数现代架构支持的指令(在 x86 上是lock cmpxchg)。这个操作的伪代码看起来像这样

int atomic_compare_exchange_pseudo(int* addr1, int* addr2, int val){
    if(*addr1 == *addr2){
        *addr1 = val;
        return 1;
    }else{
        *addr2 = *addr1;
        return 0;
    }
}

除了它是原子完成的,意味着在一个不可中断的操作中完成。部分是什么意思?原子指令也容易出现虚假失败,这意味着这些原子函数有两个版本,一个和一个,强保证成功或失败,而弱可能失败。我们使用弱部分是因为弱部分更快,而且我们在一个循环中!这意味着如果它失败得更频繁,我们也没关系,因为我们会继续旋转。

这个内存顺序是什么?我们之前讨论过内存栅栏,这就是它!我们不会详细讨论,因为这超出了本课程的范围,但不超出本文的范围。

在 while 循环内,我们未能获取到锁!我们将零重置为 unlocked 并睡一会儿。当我们醒来时,我们再次尝试获取锁。一旦成功交换,我们就进入了临界区!我们为解锁方法设置了互斥体的所有者,并返回成功。

在使用原子操作时,这如何保证互斥性,我们并不完全确定!但在这个简单的例子中,我们可以,因为能够成功期望锁处于 UNLOCKED(0)状态并将其交换到 LOCKED(1)状态的线程被认为是赢家。我们如何实现解锁?

int mutex_unlock(mutex* mtx){
    if(unlikely(pthread_self() != mtx->owner)){
        return 0; //You can't unlock a mutex if you aren't the owner
    }
    int_least8_t one = 1;
    //Critical section ends after this atomic
    mtx->owner = UNASSIGNED_OWNER;
    if(!atomic_compare_exchange_strong_explicit(
                &mtx->lock, 
                &one, 
                UNLOCKED,
                memory_order_relaxed,
                memory_order_relaxed)){
        //The mutex was never locked in the first place
        return 0;
    }
    return 1;
}

为了满足 API,除非你是拥有它的人,否则你不能解锁互斥体。然后我们取消互斥体所有者,因为在原子操作之后临界区已经结束。我们希望进行强交换,因为我们不想阻塞(pthread_mutex_unlock 不会阻塞)。我们期望互斥体被锁住,然后将其交换到解锁状态。如果交换成功,我们就解锁了互斥体。如果交换失败,这意味着互斥体是 UNLOCKED,我们试图将其从 UNLOCKED 切换到 UNLOCKED,保持解锁的非阻塞。

同步,第五部分:条件变量

条件变量简介

热身

给这些属性命名!

  • "一次只有一个进程(/线程)可以进入 CS"

  • "如果等待,那么另一个进程只能进入 CS 有限次数"

  • "如果没有其他进程在 CS 中,那么进程可以立即进入 CS"

参见Synchronization, Part 4: The Critical Section Problem获取答案。

什么是条件变量?如何使用它们?什么是虚假唤醒?

  • 条件变量允许一组线程睡眠,直到被唤醒!您可以唤醒一个线程或所有正在睡眠的线程。如果只唤醒一个线程,那么操作系统将决定唤醒哪个线程。您不直接唤醒线程,而是'信号'条件变量,然后将唤醒一个(或所有)正在条件变量内睡眠的线程。

  • 条件变量与互斥锁和循环一起使用(用于检查条件)。

  • 偶尔,一个等待的线程可能会出现无缘无故地唤醒(这称为虚假唤醒)!这不是问题,因为您总是在循环内使用wait,该循环测试必须为真才能继续。

  • 在条件变量中睡眠的线程通过调用pthread_cond_broadcast(唤醒所有)或pthread_cond_signal(唤醒一个)来唤醒。请注意,尽管函数名中有"signal",但这与 POSIX 的signal无关!

pthread_cond_wait做什么?

调用pthread_cond_wait执行三个动作:

  • 解锁互斥锁

  • 等待(睡眠,直到在同一条件变量上调用pthread_cond_signal

  • 在返回之前,锁定互斥锁

(高级话题)为什么条件变量也需要互斥锁?

条件变量需要互斥锁有三个原因。最容易理解的是,它可以防止早期唤醒消息(signalbroadcast函数)被“丢失”。想象一下以下事件序列(时间向下运行页面),其中条件在调用pthread_cond_wait之前 _ 刚好 _ 满足。在这个例子中,唤醒信号丢失了!

线程 1线程 2
while( answer < 42) {
answer++
p_cond_signal(cv)
p_cond_wait(cv,m)

如果两个线程都锁定了互斥锁,则在调用pthread_cond_wait(cv, m)之后(然后在内部解锁互斥锁)之后才能发送信号

第二个常见的原因是,更新程序状态(answer变量)通常需要互斥锁 - 例如,多个线程可能正在更新answer的值。

第三个微妙的原因是满足实时调度的考虑,我们在这里只概述:在时间关键的应用程序中,等待的线程应该允许具有最高优先级的线程先继续。为了满足这个要求,调用pthread_cond_signalpthread_cond_broadcast之前也必须锁定互斥锁。对于好奇的人,可以在这里找到更长的历史讨论。

为什么会存在虚假唤醒?

出于性能考虑。在多 CPU 系统上,可能会发生竞争条件,导致唤醒(信号)请求被忽略。内核可能不会检测到这个丢失的唤醒调用,但可以检测到可能发生的情况。为了避免潜在的丢失信号,唤醒线程以便程序代码可以再次测试条件。

例子

条件变量总是与互斥锁一起使用。

在调用wait之前,必须锁定互斥锁,并且wait必须用循环包装。

pthread_cond_t cv;
pthread_mutex_t m;
int count;

// Initialize
pthread_cond_init(&cv, NULL);
pthread_mutex_init(&m, NULL);
count = 0;

pthread_mutex_lock(&m);
while (count < 10) {
   pthread_cond_wait(&cv, &m); 
/* Remember that cond_wait unlocks the mutex before blocking (waiting)! */
/* After unlocking, other threads can claim the mutex. */
/* When this thread is later woken it will */
/* re-lock the mutex before returning */
}
pthread_mutex_unlock(&m);

//later clean up with pthread_cond_destroy(&cv); and mutex_destroy 

// In another thread increment count:
while (1) {
  pthread_mutex_lock(&m);
  count++;
  pthread_cond_signal(&cv);
  /* Even though the other thread is woken up it cannot not return */
  /* from pthread_cond_wait until we have unlocked the mutex. This is */
  /* a good thing! In fact, it is usually the best practice to call */
  /* cond_signal or cond_broadcast before unlocking the mutex */
  pthread_mutex_unlock(&m);
}

实现计数信号量

  • 我们可以使用条件变量实现计数信号量。

  • 每个信号量都需要一个计数、一个条件变量和一个互斥锁

typedef struct sem_t {
  int count; 
  pthread_mutex_t m;
  pthread_condition_t cv;
} sem_t;

实现sem_init以初始化互斥锁和条件变量

int sem_init(sem_t *s, int pshared, int value) {
  if (pshared) { errno = ENOSYS /* 'Not implemented'*/; return -1;}

  s->count = value;
  pthread_mutex_init(&s->m, NULL);
  pthread_cond_init(&s->cv, NULL);
  return 0;
}

我们的sem_post实现需要增加计数。我们还将唤醒任何在条件变量内睡眠的线程。请注意,我们锁定并解锁互斥锁,因此一次只有一个线程可以在临界区内。

sem_post(sem_t *s) {
  pthread_mutex_lock(&s->m);
  s->count++;
  pthread_cond_signal(&s->cv); /* See note */
  /* A woken thread must acquire the lock, so it will also have to wait until we call unlock*/

  pthread_mutex_unlock(&s->m);
}

我们的sem_wait实现可能需要睡眠,如果信号量的计数为零。就像sem_post一样,我们使用锁来包装临界区(这样一次只有一个线程可以执行我们的代码)。请注意,如果线程确实需要等待,那么互斥锁将被解锁,允许另一个线程进入sem_post并唤醒我们的睡眠!

请注意,即使线程被唤醒,在从pthread_cond_wait返回之前,它必须重新获取锁,因此它将不得不等待一小段时间(例如,直到sem_post完成)。

sem_wait(sem_t *s) {
  pthread_mutex_lock(&s->m);
  while (s->count == 0) {
      pthread_cond_wait(&s->cv, &s->m); /*unlock mutex, wait, relock mutex*/
  }
  s->count--;
  pthread_mutex_unlock(&s->m);
}

等待sem_post不断调用pthread_cond_signal会不会破坏sem_wait 答案:不会!在计数非零之前,我们无法跳出循环。实际上,这意味着sem_post即使没有等待的线程,也会不必要地调用pthread_cond_signal。更高效的实现只会在必要时调用pthread_cond_signal,即:

  /* Did we increment from zero to one- time to signal a thread sleeping inside sem_post */
  if (s->count == 1) /* Wake up one waiting thread!*/
     pthread_cond_signal(&s->cv);

其他信号量考虑

  • 真正的信号量实现包括队列和调度问题,以确保公平性和优先级,例如唤醒最高优先级的最长睡眠线程。

  • 另外,sem_init的高级用法允许信号量在进程之间共享。我们的实现仅适用于同一进程内的线程。

同步,第六部分:实现屏障

如何等待 N 个线程在继续下一步之前到达某一点?

假设我们想要执行一个多线程计算,它有两个阶段,但我们不想在第一阶段完成之前进入第二阶段。

我们可以使用一种称为屏障的同步方法。当一个线程到达屏障时,它将在屏障处等待,直到所有线程到达屏障,然后它们将一起继续。

想象一下,就像和一些朋友一起去远足。你们约定在每个山顶等待彼此(并且你心里记下了你的团队有多少人)。假设你是第一个到达第一个山顶的人。你会在山顶等待你的朋友。他们一个接一个地到达山顶,但直到你的团队中的最后一个人到达之前,没有人会继续前进。一旦他们到达,你们就会一起继续。

Pthreads 有一个实现这一点的函数pthread_barrier_wait()。您需要声明一个pthread_barrier_t变量,并使用pthread_barrier_init()对其进行初始化。pthread_barrier_init()将参与屏障的线程数作为参数。这里有一个例子。

现在让我们实现自己的屏障,并使用它在大型计算中同步所有线程。

double data[256][8192]

1 Threads do first calculation (use and change values in data)

2 Barrier! Wait for all threads to finish first calculation before continuing

3 Threads do second calculation (use and change values in data)

线程函数有四个主要部分-

void *calc(void *arg) {
  /* Do my part of the first calculation */
  /* Am I the last thread to finish? If so wake up all the other threads! */
  /* Otherwise wait until the other threads has finished part one */
  /* Do my part of the second calculation */
}

我们的主线程将创建 16 个线程,并将每个计算分成 16 个单独的部分。每个线程将被赋予一个唯一的值(0,1,2,..15),以便它可以处理自己的块。由于(void*)类型可以保存小整数,我们将通过将其转换为 void 指针来传递i的值。

#define N (16)
double data[256][8192] ;
int main() {
    pthread_t ids[N];
    for(int i = 0; i < N; i++)  
        pthread_create(&ids[i], NULL, calc, (void *) i);

请注意,我们永远不会将此指针值解引用为实际的内存位置-我们只会将其直接转换回整数:

void *calc(void *ptr) {
// Thread 0 will work on rows 0..15, thread 1 on rows 16..31
  int x, y, start = N * (int) ptr;
  int end = start + N; 
  for(x = start; x < end; x++) for (y = 0; y < 8192; y++) { /* do calc #1 */ }

第 1 个计算完成后,我们需要等待较慢的线程(除非我们是最后一个线程!)。因此,跟踪已经到达我们的屏障(也称为“检查点”)的线程数量:

// Global: 
int remain = N;

// After calc #1 code:
remain--; // We finished
if (remain ==0) {/*I'm last!  -  Time for everyone to wake up! */ }
else {
  while (remain != 0) { /* spin spin spin*/ }
}

然而,上述代码存在竞争条件(两个线程可能尝试递减remain),并且循环是一个忙循环。我们可以做得更好!让我们使用条件变量,然后我们将使用广播/信号函数唤醒睡眠的线程。

提醒一下,条件变量类似于一个房子!线程在那里睡觉(pthread_cond_wait)。您可以选择唤醒一个线程(pthread_cond_signal)或所有线程(pthread_cond_broadcast)。如果当前没有线程在等待,那么这两个调用将不起作用。

条件变量版本通常与忙循环不正确的解决方案非常相似-接下来我们将展示。首先,让我们添加一个互斥锁和条件全局变量,不要忘记在main中初始化它们...

//global variables
pthread_mutex_t m;
pthread_cond_t cv;

main() {
  pthread_mutex_init(&m, NULL);
  pthread_cond_init(&cv, NULL);

我们将使用互斥锁来确保只有一个线程在一次修改remain。最后到达的线程需要唤醒所有睡眠的线程-因此我们将使用pthread_cond_broadcast(&cv)而不是pthread_cond_signal

pthread_mutex_lock(&m);
remain--; 
if (remain ==0) { pthread_cond_broadcast(&cv); }
else {
  while(remain != 0) { pthread_cond_wait(&cv, &m); }
}
pthread_mutex_unlock(&m);

当线程进入pthread_cond_wait时,它释放互斥锁并进入睡眠状态。在将来的某个时刻,它将被唤醒。一旦我们将线程从睡眠中唤醒,它在返回之前必须等待直到可以锁定互斥锁。请注意,即使一个睡眠的线程提前醒来,它也会检查 while 循环条件并在必要时重新进入等待。

上述屏障不可重用这意味着如果我们将其放入任何旧的计算循环中,代码很可能会遇到屏障死锁或线程比一个迭代更快的情况。思考一下如何使上述屏障可重用,这意味着如果多个线程在循环中调用barrier_wait,则可以保证它们处于相同的迭代。

同步,第七部分:读者写者问题

读者写者问题是什么?

想象一下,您有一个键值映射数据结构,被许多线程使用。只要数据结构没有被写入,多个线程应该能够同时查找(读取)值。写者不那么合群-为了避免数据损坏,一次只有一个线程可以修改(write)数据结构(此时不能有读者正在读取)。

这是读者写者问题的一个例子。也就是说,我们如何有效地同步多个读者和写者,以便多个读者可以一起阅读,但写者可以获得独占访问?

下面显示了一个不正确的尝试(“锁”是pthread_mutex_lock的简写):

尝试#1

read() {
  lock(&m)
  // do read stuff
  unlock(&m)
}

write() {
  lock(&m)
  // do write stuff
  unlock(&m)
}

至少我们的第一次尝试不会遭受数据损坏(读者必须在写者写作时等待,反之亦然)!但是读者也必须等待其他读者。所以让我们尝试另一种实现..

尝试#2:

read() {
  while(writing) {/*spin*/}
  reading = 1
  // do read stuff
  reading = 0
}

write() {
  while(reading &#124;&#124; writing) {/*spin*/}
  writing = 1
  // do write stuff
  writing = 0
}

我们的第二次尝试遭受了竞争条件的影响-想象一下,如果两个线程同时调用readwrite(或同时调用 write)。两个线程都将能够继续进行!其次,我们可以有多个读者和多个写者,因此让我们跟踪读者或写者的总数。这就是我们尝试#3,

尝试#3

请记住,pthread_cond_wait执行个动作。首先,它会原子解锁互斥锁,然后休眠(直到被pthread_cond_signalpthread_cond_broadcast唤醒)。第三,唤醒的线程必须在返回之前重新获取互斥锁。因此,只有一个线程实际上可以在由 lock 和 unlock()方法定义的临界区域内运行。

下面的实现#3 确保如果有任何写者在写作,读者将进入 cond_wait。

read() {
    lock(&m)
    while (writing)
        cond_wait(&cv, &m)
    reading++;

/* Read here! */

    reading--
    cond_signal(&cv)
    unlock(&m)
}

但是因为候选#3 在读取之前没有解锁互斥锁,所以一次只能有一个读者读取。更好的版本在读取之前解锁:

read() {
    lock(&m);
    while (writing)
        cond_wait(&cv, &m)
    reading++;
    unlock(&m)
/* Read here! */
    lock(&m)
    reading--
    cond_signal(&cv)
    unlock(&m)
}

这是否意味着写者和读者可以同时读和写?不!首先,记住 cond_wait 要求线程在返回之前重新获取互斥锁。因此,只有一个线程可以在临界区域(用**标记)内执行代码!

read() {
    lock(&m);
**  while (writing)
**      cond_wait(&cv, &m)
**  reading++;
    unlock(&m)
/* Read here! */
    lock(&m)
**  reading--
**  cond_signal(&cv)
    unlock(&m)
}

写者必须等待所有人。互斥由锁来保证。

write() {
    lock(&m);
**  while (reading || writing)
**      cond_wait(&cv, &m);
**  writing++;
**
** /* Write here! */
**  writing--;
**  cond_signal(&cv);
    unlock(&m);
}

上述候选#3 还使用pthread_cond_signal;这只会唤醒一个线程。例如,如果许多读者正在等待写者完成,那么只有一个正在睡眠的读者将被唤醒。读者和写者应该使用cond_broadcast,以便所有线程都应该唤醒并检查它们的 while 循环条件。

饥饿的写者

上述候选#3 遭受饥饿。如果读者不断到来,那么写者将永远无法继续进行(“读取”计数永远不会减少到零)。这被称为饥饿,并且在重负载下会被发现。我们的修复方法是为写者实现有界等待。如果写者到达,他们仍然需要等待现有的读者,但是未来的读者必须被放置在“等待区”中等待写者完成。可以使用变量和条件变量来实现“等待区”(以便我们可以在写者完成后唤醒线程)。

我们的计划是,当写者到达并在等待当前读者完成之前,注册我们的写入意图(通过增加计数器'writer')。下面是草图-

write() {
    lock()
    writer++

    while (reading || writing)
    cond_wait
    unlock()
  ...
}

并且当写者为非零时,传入的读者将不被允许继续。请注意,“写者”表示写者已到达,而“读取”和“写入”计数器表示有活动读者或写者。

read() {
    lock()
    // readers that arrive *after* the writer arrived will have to wait here!
    while(writer)
    cond_wait(&cv,&m)

    // readers that arrive while there is an active writer
    // will also wait.
    while (writing) 
        cond_wait(&cv,&m)
    reading++
    unlock
  ...
}

尝试#4

以下是我们对读者-写者问题的第一个工作解决方案。请注意,如果你继续阅读关于“读者写者问题”的内容,你会发现我们通过给予写者对锁的优先访问来解决了“第二个读者写者问题”。这个解决方案并不是最佳的。然而,它满足了我们最初的问题(N 个活跃读者,单个活跃写者,避免了如果有持续的读者流的话写者饥饿)。

你能识别出任何改进吗?例如,你会如何改进代码,以便我们只唤醒读者或一个写者?

int writers; // Number writer threads that want to enter the critical section (some or all of these may be blocked)
int writing; // Number of threads that are actually writing inside the C.S. (can only be zero or one)
int reading; // Number of threads that are actually reading inside the C.S.
// if writing !=0 then reading must be zero (and vice versa)

reader() {
    lock(&m)
    while (writers)
        cond_wait(&turn, &m)
    // No need to wait while(writing here) because we can only exit the above loop
    // when writing is zero
    reading++
    unlock(&m)

  // perform reading here

    lock(&m)
    reading--
    cond_broadcast(&turn)
    unlock(&m)
}

writer() {
    lock(&m)  
    writers++  
    while (reading || writing)   
        cond_wait(&turn, &m)  
    writing++  
    unlock(&m)  
    // perform writing here 
    lock(&m)  
    writing--  
    writers--  
    cond_broadcast(&turn)  
    unlock(&m)  
}

同步,第八部分:环形缓冲区示例

什么是环形缓冲区?

环形缓冲区是一种简单的、通常是固定大小的存储机制,其中连续的内存被视为循环的,并且两个索引计数器跟踪队列的当前开始和结束。由于数组索引不是循环的,所以当移动到数组的末尾时,索引计数器必须回绕到零。当数据被添加(入队)到队列的前端或从队列的尾部移除(出队)时,缓冲区中的当前项目形成一个似乎环绕轨道的列车!一个简单的(单线程)实现如下所示。请注意,enqueue 和 dequeue 没有防止下溢或上溢——当队列已满时可能添加一个项目,当队列为空时可能移除一个项目。例如,如果我们向队列中添加了 20 个整数(1,2,3...),并且没有移除任何项目,那么值17,18,19,20将覆盖1,2,3,4。我们现在不会解决这个问题,而是在创建多线程版本时,我们将确保在环形缓冲区已满或为空时,enqueue 和 dequeue 线程被阻塞。

void *buffer[16];
int in = 0, out = 0;

void enqueue(void *value) { /* Add one item to the front of the queue*/
  buffer[in] = value;
  in++; /* Advance the index for next time */
  if (in == 16) in = 0; /* Wrap around! */
}

void *dequeue() { /* Remove one item to the end of the queue.*/
  void *result = buffer[out];
  out++;
  if (out == 16) out = 0;
  return result;
}

实现环形缓冲区的注意事项是什么?

很容易写出 enqueue 或 dequeue 方法的以下紧凑形式(N 是缓冲区的容量,例如 16):

void enqueue(void *value)
  b[ (in++) % N ] = value;
}

这种方法似乎可以工作(通过简单的测试等),但包含一个微妙的错误。通过足够多的 enqueue 操作(略多于 20 亿次),in的 int 值将溢出并变为负数!模运算符保留符号。因此,你可能会写入b[-14],例如!

一个紧凑的形式是正确的使用位掩码,提供 N 是 2^x(16,32,64,...)

b[ (in++) & (N-1) ] = value;

这个缓冲区还没有防止缓冲区下溢或上溢。为此,我们将转向我们的多线程尝试,它将阻塞一个线程,直到有空间或至少有一个项目可以移除。

检查多线程实现的正确性(示例 1)

以下代码是一个不正确的实现。会发生什么?enqueue和/或dequeue会阻塞吗?互斥性是否得到满足?缓冲区会下溢吗?缓冲区会上溢吗?为了清晰起见,pthread_mutex缩写为p_m,我们假设 sem_wait 不会被中断。

#define N 16
void *b[N]
int in = 0, out = 0
p_m_t lock
sem_t s1,s2
void init() { 
    p_m_init(&lock, NULL)
    sem_init(&s1, 0, 16)
    sem_init(&s2, 0, 0)
}

enqueue(void *value) {
    p_m_lock(&lock)

    // Hint: Wait while zero. Decrement and return
    sem_wait( &s1 ) 

    b[ (in++) & (N-1) ] = value

    // Hint: Increment. Will wake up a waiting thread 
    sem_post(&s1) 
    p_m_unlock(&lock)
}
void *dequeue(){
    p_m_lock(&lock)
    sem_wait(&s2)
    void *result = b[(out++) & (N-1) ]
    sem_post(&s2)
    p_m_unlock(&lock)
    return result
}

分析

在继续阅读之前,看看你能找到多少错误。然后确定如果线程调用 enqueue 和 dequeue 方法会发生什么。

  • enqueue 方法在同一个信号量(s1)上等待和发布,equeue 也是如此(s2)即我们减少值然后立即增加值,因此在函数结束时,信号量值不变!

  • s1 的初始值为 16,因此信号量永远不会减少到零——如果环形缓冲区已满,enqueue 不会阻塞——因此可能会发生溢出。

  • s2 的初始值为零,因此调用 dequeue 将始终阻塞并且永远不会返回!

  • 互斥锁和 sem_wait 的顺序需要交换(但是这个示例是如此破碎,以至于这个错误没有影响!)##检查多线程实现的正确性(示例 1)

以下代码是一个不正确的实现。会发生什么?enqueue和/或dequeue会阻塞吗?互斥性是否得到满足?缓冲区会下溢吗?缓冲区会上溢吗?为了清晰起见,pthread_mutex缩写为p_m,我们假设 sem_wait 不会被中断。

void *b[16]
int in = 0, out = 0
p_m_t lock
sem_t s1, s2
void init() {
    sem_init(&s1,0,16)
    sem_init(&s2,0,0)
}

enqueue(void *value){

 sem_wait(&s2)
 p_m_lock(&lock)

 b[ (in++) & (N-1) ] = value

 p_m_unlock(&lock)
 sem_post(&s1)
}

void *dequeue(){
  sem_wait(&s1)
  p_m_lock(&lock)
  void *result = b[(out++) & 15]
  p_m_unlock(&lock)
  sem_post(&s2)

  return result;
}

分析

  • s2 的初始值为 0。因此,在第一次调用 sem_wait 时,enqueue 将阻塞,即使缓冲区为空!

  • s1 的初始值为 16。因此,在第一次调用 sem_wait 时,dequeue 不会阻塞,即使缓冲区为空——糟糕,下溢!dequeue 方法将返回无效数据。

  • 该代码不满足互斥性;两个线程可以同时修改 inout! 该代码似乎使用了互斥锁。不幸的是,该锁从未使用 pthread_mutex_init()PTHREAD_MUTEX_INITIALIZER 进行初始化 - 因此该锁可能无效(pthread_mutex_lock 可能什么也不做)

正确实现环形缓冲区

伪代码(pthread_mutex 缩写为 p_m 等)如下所示。

由于互斥锁存储在全局(静态)内存中,因此可以使用 PTHREAD_MUTEX_INITIALIZER 进行初始化。如果我们在堆上为互斥锁分配了空间,那么我们将使用 pthread_mutex_init(ptr, NULL)

#include <pthread.h>
#include <semaphore.h>
// N must be 2^i
#define N (16)

void *b[N]
int in = 0, out = 0
p_m_t lock = PTHREAD_MUTEX_INITIALIZER
sem_t countsem, spacesem

void init() {
  sem_init(&countsem, 0, 0)
  sem_init(&spacesem, 0, 16)
}

enqueue 方法如下所示。请注意:

  • 该锁仅在临界区(对数据结构的访问)期间保持。

  • 完整的实现需要防止由于 POSIX 信号而导致 sem_wait 提前返回。

enqueue(void *value){
 // wait if there is no space left:
 sem_wait( &spacesem )

 p_m_lock(&lock)
 b[ (in++) & (N-1) ] = value
 p_m_unlock(&lock)

 // increment the count of the number of items
 sem_post(&countsem)
}

dequeue 实现如下所示。请注意 enqueue 的同步调用的对称性。在两种情况下,如果空间计数或项目计数为零,函数首先会等待。

void *dequeue(){
  // Wait if there are no items in the buffer
  sem_wait(&countsem)

  p_m_lock(&lock)
  void *result = b[(out++) & (N-1)]
  p_m_unlock(&lock)

  // Increment the count of the number of spaces
  sem_post(&spacesem)

  return result
}

思考

  • 如果 pthread_mutex_unlocksem_post 调用的顺序被交换会发生什么?

  • 如果 sem_waitpthread_mutex_lock 调用的顺序被交换会发生什么?

同步复习问题

主题

  • 原子操作

  • 临界区

  • 生产者消费者问题

  • 使用条件变量

  • 使用计数信号量

  • 实现一个屏障

  • 实现环形缓冲区

  • 使用 pthread_mutex

  • 实现生产者消费者

  • 分析多线程代码

问题

  • 什么是原子操作?

  • 为什么以下内容在并行代码中不起作用?

//In the global section
size_t a;
//In pthread function
for(int i = 0; i < 100000000; i++) a++;

这将会是什么?

//In the global section
atomic_size_t a;
//In pthread function
for(int i = 0; i < 100000000; i++) atomic_fetch_add(a, 1);
  • 原子操作有哪些缺点?哪个更快:保留一个本地变量还是进行多个原子操作?

  • 什么是临界区?

  • 一旦确定了临界区,保证只有一个线程会进入该区域的一种方法是什么?

  • 在这里确定临界区

struct linked_list;
struct node;
void add_linked_list(linked_list *ll, void* elem){
    node* packaged = new_node(elem);
    if(ll->head){
         ll->head = 
    }else{
         packaged->next = ll->head;
         ll->head = packaged;
         ll->size++;
    }

}

void* pop_elem(linked_list *ll, size_t index){
    if(index >= ll->size) return NULL;

    node *i, *prev;
    for(i = ll->head; i && index; i = i->next, index--){
        prev = i;
    }

    //i points to the element we need to pop, prev before
    if(prev->next) prev->next = prev->next->next;
    ll->size--;
    void* elem = i->elem;
    destroy_node(i);
    return elem;
}

临界区可以有多紧凑?

  • 什么是生产者消费者问题?以上述部分如何成为生产者消费者问题?生产者消费者问题与读者写者问题有什么关系?

  • 什么是条件变量?为什么使用条件变量比使用“while”循环更有优势?

  • 为什么这段代码很危险?

if(not_ready){
     pthread_cond_wait(&cv, &mtx);
}
  • 什么是计数信号量?给我一个类似于饼干罐/比萨盒/有限食物的比喻。

  • 什么是线程屏障?

  • 使用计数信号量来实现屏障。

  • 编写一个生产者/消费者队列,再来一个生产者/消费者栈?

  • 给我一个使用条件变量的读者-写者锁的实现,使用你需要的任何结构,它只需要支持以下函数

void reader_lock(rw_lock_t* lck);
void writer_lock(rw_lock_t* lck);
void reader_unlock(rw_lock_t* lck);
void writer_unlock(rw_lock_t* lck);

唯一的规定是在“reader_lock”和“reader_unlock”之间,没有写者可以写。在写者锁之间,只有一个写者可以一次写作。

  • 编写代码使用仅三个计数信号量实现生产者消费者。假设可以有多个线程调用 enqueue 和 dequeue。确定每个信号量的初始值。

  • 编写代码使用条件变量和互斥锁实现生产者消费者。假设可以有多个线程调用 enqueue 和 dequeue。

  • 使用 CVs 实现 add(unsigned int)和 subtract(unsigned int)阻塞函数,永远不允许全局值大于 100。

  • 使用 CVs 为 15 个线程实现一个屏障。

  • 以下陈述有多少是真的?

    • 可以有多个活跃的读者

    • 可以有多个活跃的写者

    • 当有活跃的写者时,活跃的读者数量必须为零

    • 如果有活跃的读者,则活跃的写者数量必须为零

    • 一个写者必须等到当前活跃的读者完成

  • 待办事项:分析多线程代码片段

六、死锁

死锁,第一部分:资源分配图

什么是资源分配图?

资源分配图跟踪哪个进程持有哪个资源,以及哪个进程正在等待特定类型的资源。这是一个非常强大而简单的工具,用来说明交互进程如何发生死锁。如果一个进程使用一个资源,就从资源节点到进程节点画一个箭头。如果一个进程请求一个资源,就从进程节点到资源节点画一个箭头。

如果资源分配图中有一个循环,并且循环中的每个资源只提供一个实例,那么进程将发生死锁。例如,如果进程 1 持有资源 A,进程 2 持有资源 B,进程 1 正在等待 B,进程 2 正在等待 A,那么进程 1 和 2 将发生死锁。

这里有另一个例子,显示了进程 1 和 2 获取资源 1 和 2,而进程 3 正在等待获取这两个资源。在这个例子中,没有死锁,因为没有循环依赖。

ResourceAllocationGraph-Ex1.png

死锁!

很多时候,我们不知道资源可能被获取的具体顺序,所以我们可以绘制有向图。

作为可能性矩阵。然后我们可以画箭头,看看是否有一个有向版本会导致死锁。

RAG 死锁

考虑以下资源分配图(假设进程请求对文件的独占访问)。如果有一堆进程在运行,并且它们请求资源,操作系统最终处于这种状态,你就会发生死锁!你可能看不到这一点,因为操作系统可能会抢占一些进程来打破循环,但你的三个孤独进程仍然有可能发生死锁。你也可以使用make和规则依赖关系(例如我们的 parmake MP)制作这种类型的图表。

死锁,第二部分:死锁条件

Coffman 条件

死锁有四个必要充分条件。这些被称为 Coffman 条件。

  • 互斥

  • 循环等待

  • 持有并等待

  • 无抢占

如果打破其中任何一个,就不会发生死锁!

所有这些条件都是死锁所必需的,所以让我们依次讨论每一个。首先是简单的-

  • 互斥:资源不能被共享

  • 循环等待:资源分配图中存在一个循环。存在一组进程{P1,P2,...},使得 P1 正在等待 P2 持有的资源,P2 正在等待 P3,...,P3 正在等待 P1 持有的资源。

  • 持有并等待:一个进程获取了一个不完整的资源集,并在等待其他资源时保持它们。

  • 无抢占:一旦一个进程获取了一个资源,该资源就不能被从一个进程那里拿走,而且进程也不会自愿放弃一个资源。

打破 Coffman 条件

两个学生需要一支笔和一张纸:

  • 学生们共享一支笔和一张纸。避免了死锁,因为不需要互斥。

  • 学生们都同意先拿笔再拿纸。避免了死锁,因为不会有循环等待。

  • 学生们一次拿起笔和纸(“要么都拿,要么都不拿”)。避免了死锁,因为没有持有并等待

  • 学生们是朋友,会要求对方放弃持有的资源。避免了死锁,因为允许抢占。

活锁

活锁不是死锁-

考虑以下的“解决方案”

  • 如果他们无法在 10 秒内拿起另一个资源,学生们会放下一个持有的资源。这个解决方案避免了死锁,但可能会遭受活锁。

活锁发生在一个进程继续执行但无法取得进展。在实践中,活锁可能是因为程序员已经采取措施避免死锁。在上面的例子中,在繁忙的系统中,学生将不断释放第一个资源,因为他们永远无法获得第二个资源。系统不是死锁(学生进程仍在执行),但也没有取得任何进展。

死锁预防/避免 vs 死锁检测

死锁预防是确保死锁不会发生,这意味着你打破了 Coffman 条件。这在单个程序内效果最好,软件工程师可以选择打破某个 Coffman 条件。考虑银行家算法。这是另一个用于避免死锁的算法。整个实现超出了本课程的范围,只需知道操作系统有更通用的算法。

另一方面,死锁检测允许系统进入死锁状态。进入后,系统使用其拥有的信息来打破死锁。例如,考虑多个进程访问文件。操作系统能够通过文件描述符在某个级别(通过 API 或直接)跟踪所有文件/资源。如果操作系统在操作系统文件描述符表中检测到一个有向循环,它可能会打破一个进程的持有(例如通过调度)并让系统继续进行。

餐桌哲学家

餐桌哲学家问题是一个经典的同步问题。想象我邀请 N(假设为 5)位哲学家共进晚餐。我们将他们安排在一张桌子旁,放置 5 根筷子(每位哲学家之间各有一根)。哲学家交替地想要吃饭或思考。为了吃饭,哲学家必须拿起他们位置两侧的两根筷子(原始问题要求每位哲学家有两把叉子)。然而这些筷子是与他的邻居共享的。

5DiningPhilosophers

设计一种有效的解决方案,使所有哲学家都能吃饭吗?或者,会有一些哲学家挨饿,永远得不到第二根筷子吗?或者他们全部陷入僵局?例如,想象每个客人都拿起左边的筷子,然后等待右边的筷子空闲。哎呀 - 我们的哲学家陷入了僵局!

死锁,第三部分:餐桌上的哲学家

背景故事

所以你的哲学家们围坐在桌子周围,都想吃点意大利面(或者其他什么),他们真的很饿。每个哲学家本质上都是一样的,这意味着每个哲学家都有相同的指令集,基于其他哲学家,也就是说你不能让每个偶数哲学家做一件事,每个奇数哲学家做另一件事。

失败的解决方案

左右死锁

我们该怎么办?让我们尝试一个简单的解决方案

void* philosopher(void* forks){
     info phil_info = forks;
     pthread_mutex_t* left_fork = phil_info->left_fork;
     pthread_mutex_t* right_fork = phil_info->right_fork;
     while(phil_info->simulation){
          pthread_mutex_lock(left_fork);
          pthread_mutex_lock(right_fork);
          eat(left_fork, right_fork);
          pthread_mutex_unlock(left_fork);
          pthread_mutex_unlock(right_fork);
     }
}

但是这会遇到一个问题!如果每个人都拿起他们的左手叉子,正在等待他们的右手叉子呢?我们已经死锁了程序。重要的是要注意,死锁并不总是发生,而且这个解决方案死锁的概率随着哲学家的数量增加而降低。真正重要的是,最终这个解决方案会死锁,让线程挨饿,这是不好的。

Trylock?更像是活锁

所以现在你在考虑打破柯夫曼条件之一。我们有

  • 互斥

  • 没有抢占

  • 持有并等待

  • 循环等待

嗯,我们不能让两个哲学家同时使用一个叉子,互斥被排除在外。在我们当前的简单模型中,我们不能让哲学家一旦拿到互斥锁就放开它,所以我们现在就排除这个解决方案——关于这个解决方案有一些注释在页面底部。让我们打破持有并等待!

void* philosopher(void* forks){
     info phil_info = forks;
     pthread_mutex_t* left_fork = phil_info->left_fork;
     pthread_mutex_t* right_fork = phil_info->right_fork;
     while(phil_info->simulation){
          pthread_mutex_lock(left_fork);
          int failed = pthread_mutex_trylock(right_fork);
          if(!failed){
               eat(left_fork, right_fork);
               pthread_mutex_unlock(right_fork);
          }
          pthread_mutex_unlock(left_fork);
     }
}

现在我们的哲学家拿起左边的叉子,试图抓住右边的叉子。如果右边的叉子可用,他们就吃。如果不可用,他们放下左边的叉子再试一次。没有死锁!

但是,有一个问题。如果所有的哲学家同时拿起他们的左手,试图抓住他们的右手,放下他们的左手,再拿起他们的左手,试图抓住他们的右手……我们现在活锁了我们的解决方案!我们可怜的哲学家们仍然饿着,所以让我们给他们一些合适的解决方案。

可行的解决方案

仲裁者(天真和高级)。

天真的仲裁者解决方案是有一个仲裁者(例如一个互斥锁)。让每个哲学家请求仲裁者的许可来吃饭。这个解决方案一次只允许一个哲学家吃饭。当他们吃完后,另一个哲学家可以请求吃饭的许可。

这可以防止死锁,因为没有循环等待!没有哲学家需要等待其他哲学家。

高级仲裁者解决方案是实现一个类,确定哲学家的叉子是否在仲裁者的控制下。如果是,他们把叉子给哲学家,让他吃,然后拿回叉子。这有一个额外的好处,就是能够让多个哲学家同时吃饭。

问题:

  • 这些解决方案很慢

  • 他们有一个单一的故障点,仲裁者使其成为一个瓶颈

  • 仲裁者在第二个解决方案中也需要公平,并且能够确定死锁

  • 在实际系统中,仲裁者倾向于重复地将叉子交给刚刚吃过的哲学家,因为进程调度

离开桌子(Stallings 的解决方案)

为什么第一个解决方案会死锁?嗯,有 n 个哲学家和 n 根筷子。如果桌子上只有 1 个哲学家怎么办?我们会死锁吗?不会。

2 个哲学家怎么样?3 个?……你可以看出这是怎么回事。Stallings 的解决方案是从桌子上移除哲学家,直到死锁不可能发生——想想桌子上的哲学家的魔数是多少。在实际系统中,通过信号量来实现这一点,并让一定数量的哲学家通过。

问题:

  • 解决方案需要大量的上下文切换,这对 CPU 来说非常昂贵

  • 你需要提前知道资源的数量,以便只让那么多的哲学家

  • 再次优先考虑那些已经吃过的进程。

部分排序(Dijkstra 的解决方案)

这是 Dijkstra 的解决方案(他是在考试中提出这个问题的人)。为什么第一个解决方案会死锁?Dijkstra 认为最后一个拿起他左边叉子的哲学家(导致解决方案死锁)应该拿起他的右边叉子。他通过给叉子编号 1..n,并告诉每个哲学家拿起他较小编号的叉子来实现这一点。

让我们再次运行死锁条件。每个人都试图先拿起他们较小编号的叉子。哲学家 1 拿到叉子 1,哲学家 2 拿到叉子 2,依此类推,直到我们到达哲学家 n。他们必须在叉子 1 和 n 之间做出选择。叉子 1 已经被哲学家 1 拿起,所以他们不能拿起那个叉子,这意味着他不会拿起叉子 n。我们打破了循环等待!这意味着死锁是不可能的。

问题:

  • 哲学家在抓取任何资源之前需要知道资源的集合顺序。

  • 您需要为所有资源定义一个偏序。

  • 优先考虑已经吃过饭的哲学家。

高级解决方案

还有许多更高级的解决方案,非穷尽列表包括

  • 干净/脏叉子(钱德拉/米斯拉解决方案)

  • 演员模型(其他消息传递模型)

  • 超级仲裁者(复杂的管道)

死锁复习问题

主题

Coffman 条件资源分配图餐厅哲学家

  • 失败的 DP 解决方案

  • 活锁 DP 解决方案

  • 工作的 DP 解决方案:优缺点

问题

  • 科夫曼条件是什么?

  • 科夫曼条件的每个意思是什么?(例如,你能提供每个条件的定义吗?)

  • 举一个打破科夫曼条件的真实例子。一个需要考虑的情况:画家,油漆,画笔等。你如何确保工作会完成?

  • 能够识别餐厅哲学家代码何时导致死锁(或者不导致)。例如,如果你看到以下代码片段,哪个科夫曼条件没有满足?

// Get both locks or none.
pthread_mutex_lock( a );
if( pthread_mutex_trylock( b ) ) { /*failed*/
   pthread_mutex_unlock( a );
   ...
}
  • 如果一个线程调用
  pthread_mutex_lock(m1) // success
  pthread_mutex_lock(m2) // blocks

还有另一个线程调用

  pthread_mutex_lock(m2) // success
  pthread_mutex_lock(m1) // blocks

发生了什么,为什么?如果第三个线程调用pthread_mutex_lock(m1)会发生什么?

  • 有多少进程被阻塞?通常情况下,假设一个进程能够完成,如果它能够获取下面列出的所有资源。

    • P1 获取 R1

    • P2 获取 R2

    • P1 获取 R3

    • P2 等待 R3

    • P3 获取 R5

    • P1 等待 R4

    • P3 等待 R1

    • P4 等待 R5

    • P5 等待 R1

(画出资源图!)

七、进程间通信和调度

虚拟内存,第一部分:虚拟内存简介

什么是虚拟内存?

在非常简单的嵌入式系统和早期计算机中,进程直接访问内存,即“地址 1234”对应于物理内存的特定部分中存储的特定字节。在现代系统中,情况已经不再是这样。相反,每个进程都是隔离的;并且存在着一个地址转换过程,将进程的特定 CPU 指令或数据的地址与物理内存(“RAM”)的实际字节对应起来。内存地址不再是“真实的”;进程在虚拟内存中运行。虚拟内存不仅可以保护进程的安全(因为一个进程不能直接读取或修改另一个进程的内存),还允许系统有效地分配和重新分配内存的部分给不同的进程。

MMU 是什么?

内存管理单元是 CPU 的一部分。它将虚拟内存地址转换为物理地址。如果当前没有从特定虚拟地址到物理地址的映射,或者当前 CPU 指令尝试写入进程只有读取访问权限的位置,MMU 也可能中断 CPU。

那么我们如何将虚拟地址转换为物理地址?

想象一下你有一台 32 位的机器。指针可以保存 32 位,即它们可以寻址 2^32 个不同的位置,即 4GB 的内存(我们将遵循一个地址可以保存一个字节的标准约定)。

想象我们有一个大表 - 这是聪明的部分 - 存储在内存中!对于每个可能的地址(共 40 亿个),我们将存储“真实”即物理地址。每个物理地址将需要 4 个字节(以容纳 32 位)。这种方案将需要 160 亿字节来存储所有条目。哎呀 - 我们的查找方案将消耗我们可能为我们的 4GB 机器购买的所有内存。我们需要做得比这更好。我们的查找表最好比我们拥有的内存小,否则我们将没有空间留给我们的实际程序和操作系统数据。解决方案是将内存分成称为“页面”和“帧”的小区域,并为每个页面使用查找表。

什么是页面?有多少个页面?

页面是一块虚拟内存。Linux 操作系统上的典型块大小为 4KB(即 2^12 个地址),尽管您可以找到更大块的示例。

因此,我们不再谈论单个字节,而是谈论 4KB 的块,每个块称为一个页面。我们还可以对我们的页面进行编号(“页面 0”“页面 1”等)

例如:32 位机器有多少页(假设页面大小为 4KB)?

答案:2^32 地址 / 2^12 = 2^20 页。

记住 2^10 是 1024,所以 2^20 略大于一百万。

对于 64 位机器,2^64 / 2^12 = 2^52,大约是 10^15 页。

什么是帧?

帧(有时称为“页帧”)是一块物理内存或 RAM(=随机存取存储器)。这种内存有时被称为“主存储器”(与较慢的辅助存储器相对,例如具有较低访问时间的旋转磁盘)

一个帧的字节数与虚拟页面相同。如果 32 位机器有 2^32(4GB)的 RAM,那么在机器的可寻址空间中将有相同数量的帧。64 位机器不太可能有 2^64 字节的 RAM - 你能看出为什么吗?

什么是页面表,它有多大?

页面表是页面到帧之间的映射。例如,页面 1 可能映射到帧 45,页面 2 映射到帧 30。其他帧可能目前未使用或分配给其他正在运行的进程,或者由操作系统内部使用。

一个简单的页面表就是一个数组,int frame = table[ page_num ];

对于一个 32 位机器,每个 4KB 页面的条目需要保存一个帧号-即 20 位,因为我们计算出有 2^20 个帧。每个条目需要 2.5 个字节!实际上,我们将每个条目四舍五入到 4 个字节,并找到这些多余位的用途。每个条目需要 4 个字节 x 2^20 个条目= 4MB 的物理内存用于存储页表。

对于一个 64 位机器,每个 4KB 页面的条目需要 52 位。让我们将每个条目四舍五入到 64 位(8 字节)。有 2^52 个条目,大约需要 2^55 字节(大约 40PB...)哎呀,我们的页表太大了。

在 64 位体系结构中,内存地址是稀疏的,因此我们需要一种机制来减小页表的大小,因为大多数条目永远不会被使用。

这里有一个页表的视觉示例。想象访问一个数组并获取数组元素。

偏移量是什么,它是如何使用的?

记住,我们的页表将页面映射到帧,但每个页面都是一块连续的地址。我们如何计算在特定帧内使用哪个特定字节?解决方案是直接重用虚拟内存地址的最低位。例如,假设我们的进程正在读取以下地址- VirtualAddress = 11110000111100001111000010101010(二进制)

在页面大小为 256 字节的机器上,最低的 8 位(10101010)将被用作偏移量。剩下的上位位将是页号(111100001111000011110000)。

多级页表

多级页面是 64 位体系结构的页表大小问题的一种解决方案。我们将看看最简单的实现-两级页表。每个表都是指向下一级表的指针列表,不需要所有子表都存在。下面是 32 位体系结构的两级页表的示例-

VirtualAddress = 11110000111111110000000010101010 (binary)
                 |_Index1_||        ||          | 10 bit Directory index
                           |_Index2_||          | 10 bit Sub-table index
                                     |__________| 12 bit offset (passed directly to RAM) 

在上述方案中,确定帧号需要两次内存读取:使用顶部的 10 位在页表目录中。如果每个条目使用 2 个字节,我们只需要 2KB 来存储整个目录。每个子表将指向物理帧(即需要 4 个字节来存储 20 位)。然而,对于只需要微小内存的进程,我们只需要指定低内存地址(用于堆和程序代码)和高内存地址(用于堆栈)的条目。每个子表是 1024 个条目 x 4 个字节,即每个子表需要 4KB。因此,我们的多级页表的总内存开销已经从 4MB(单级)减少到 3 帧内存(12KB)!

页表会使内存访问变慢吗?(TLB 是什么)

是的-显著!(但由于聪明的硬件,通常不会...)与直接读取或写入内存相比。对于单个页表,我们的机器现在慢了一倍!(需要两次内存访问)对于两级页表,内存访问现在慢了三倍。(需要三次内存访问)

为了克服这种开销,MMU 包括一个最近使用的虚拟页到帧查找的关联缓存。这个缓存被称为 TLB(“转换旁路缓冲区”)。每当需要将虚拟地址转换为物理内存位置时,TLB 与页表并行查询。对于大多数程序的大多数内存访问,TLB 缓存结果的机会很大。但是,如果一个程序没有良好的缓存一致性(例如从许多不同页面的随机内存位置读取),那么 TLB 将不会有结果缓存,现在 MMU 必须使用速度慢得多的页表来确定物理帧。

这可能是如何分割多级页表的方式。

高级帧和页面保护

帧可以在进程之间共享吗?它们可以被专门化吗?

是的!除了存储帧编号之外,页面表还可以用于存储进程是否可以写入或只读特定帧。只读帧可以安全地在多个进程之间共享。例如,C 库指令代码可以在所有动态将代码加载到进程内存中的进程之间共享。每个进程只能读取该内存。这意味着如果您尝试写入内存中的只读页面,您将收到SEGFAULT。这就是为什么有时内存访问会导致段错误,有时不会,这完全取决于您的硬件是否允许您访问。

此外,进程可以使用mmap系统调用与子进程共享页面。mmap是一个有趣的调用,因为它不是将每个虚拟地址绑定到物理帧,而是绑定到其他东西。这个其他东西可以是文件、GPU 单元或者你能想到的任何其他内存映射操作!写入内存地址可能会直接写入设备,或者写入可能会被操作系统暂停,但这是一个非常强大的抽象,因为操作系统通常能够执行优化(多个进程内存映射相同的文件可以让内核创建一个映射)。

页面表中还存储了什么,以及为什么?

除了上面讨论的只读位和使用统计信息之外,通常至少存储只读、修改和执行信息。

什么是页面故障?

页面故障是指运行中的程序尝试访问其地址空间中未映射到物理内存的某些虚拟内存。页面故障也会在其他情况下发生。

有三种类型的页面故障

次要 如果页面尚未映射,但是是有效地址。这可能是sbrk(2)要求的内存,但尚未写入,这意味着操作系统可以在分配空间之前等待第一次写入。操作系统只需创建页面,将其加载到内存中,然后继续进行。

主要 如果页面的映射不在内存中而在磁盘上。这将会将页面交换到内存中,并将另一个页面交换出去。如果这种情况发生频繁,您的程序就会被称为抖动MMU。

无效 当您尝试写入不可写内存地址或读取不可读内存地址时。MMU 会生成一个无效故障,操作系统通常会生成一个SIGSEGV,表示分段违规,这意味着您写入了超出您可以写入的段的位置。

只读位

只读位将页面标记为只读。尝试写入页面将导致页面故障。然后内核将处理页面故障。只读页面的两个例子包括在多个进程之间共享 c 运行时库(出于安全考虑,您不希望允许一个进程修改库);以及写时复制,其中复制页面的成本可以延迟到第一次写入发生时。

脏位

en.wikipedia.org/wiki/Page_table#Page_table_data

脏位允许进行性能优化。从磁盘分页到物理内存,然后再次读取,然后再次分页出去的页面不需要写回磁盘,因为页面没有更改。但是,如果页面在分页后被写入,其脏位将被设置,表示页面必须写回备份存储。这种策略要求备份存储在将页面分页到内存后保留页面的副本。当不使用脏位时,备份存储只需与任何时刻分页出的所有页面的瞬时总大小一样大。当使用脏位时,始终会存在一些页面既存在于物理内存中又存在于备份存储中。

执行位

执行位定义了页面中的字节是否可以作为 CPU 指令执行。通过禁用页面,可以防止恶意存储在进程内存中的代码(例如通过堆栈溢出)被轻易执行。(更多阅读:en.wikipedia.org/wiki/NX_bit#Hardware_background

了解更多

在 x86 平台上,有关分页和页面位的更低级别的技术讨论可在[wiki.osdev.org/Paging]找到。

管道,第一部分:管道介绍

什么是 IPC?

进程间通信是一个进程与另一个进程交流的任何方式。你已经看到了这种虚拟内存的一种形式!一块虚拟内存可以在父进程和子进程之间共享,从而进行通信。你可能想把那块内存包装在pthread_mutexattr_setpshared(&attrmutex, PTHREAD_PROCESS_SHARED);互斥锁(或者进程范围的互斥锁)中,以防止竞争条件。

还有更多标准的 IPC 方式,比如管道!考虑一下,如果你在终端中输入以下内容

$ ls -1 | cut -d'.' -f1 | uniq | sort | tee dir_contents

以下代码做了什么(如果你愿意,你可以跳过这个)?好吧,它ls当前目录(-1 表示它每行输出一个条目)。然后cut命令取得第一个句点之前的所有内容。Uniq 确保所有行都是唯一的,sort 对它们进行排序,tee 输出到一个文件。

重要的部分是 bash 创建了5 个单独的进程并将它们的标准输出/标准输入与管道连接起来,轨迹看起来像这样。

(0) ls (1)------>(0) cut (1)------->(0) uniq (1)------>(0) sort (1)------>(0) tee (1)

管道中的数字是每个进程的文件描述符,箭头表示重定向或管道输出的位置。

什么是管道?

POSIX 管道几乎像它的真实对应物 - 你可以把字节塞进一端,它们会按照相同的顺序出现在另一端。然而,与真实的管道不同,流动方向总是相同的,一个文件描述符用于读取,另一个用于写入。pipe系统调用用于创建管道。

int filedes[2];
pipe (filedes);
printf("read from %d, write to %d\n", filedes[0], filedes[1]);

这些文件描述符可以与read一起使用 -

// To read...
char buffer[80];
int bytesread = read(filedes[0], buffer, sizeof(buffer));

write -

write(filedes[1], "Go!", 4);

我怎样使用管道与子进程通信?

使用管道的常见方法是在分叉之前创建管道。

int filedes[2];
pipe (filedes);
pid_t child = fork();
if (child > 0) { /* I must be the parent */
    char buffer[80];
    int bytesread = read(filedes[0], buffer, sizeof(buffer));
    // do something with the bytes read 
}

然后子进程可以向父进程发送消息:

if (child == 0) {
   write(filedes[1], "done", 4);
}

我可以在单个进程中使用管道吗?

简短回答:是的,但我不确定你为什么要这样做 LOL!

以下是一个向自己发送消息的示例程序:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    int fh[2];
    pipe(fh);
    FILE *reader = fdopen(fh[0], "r");
    FILE *writer = fdopen(fh[1], "w");
    // Hurrah now I can use printf rather than using low-level read() write()
    printf("Writing...\n");
    fprintf(writer,"%d %d %d\n", 10, 20, 30);
    fflush(writer);

    printf("Reading...\n");
    int results[3];
    int ok = fscanf(reader,"%d %d %d", results, results + 1, results + 2);
    printf("%d values parsed: %d %d %d\n", ok, results[0], results[1], results[2]);

    return 0;
}

以这种方式使用管道的问题在于写入管道可能会阻塞,即管道只有有限的缓冲容量。如果管道已满,写入进程将被阻塞!缓冲区的最大大小取决于系统;典型值从 4KB 到 128KB。

int main() {
    int fh[2];
    pipe(fh);
    int b = 0;
    #define MESG "..............................."
    while(1) {
        printf("%d\n",b);
        write(fh[1], MESG, sizeof(MESG))
        b+=sizeof(MESG);
    }
    return 0;
}

参见Pipes,第二部分:管道编程秘密

管道,第二部分:管道编程秘密

管道陷阱

这里有一个完整的例子,但不起作用!子进程每次从管道中读取一个字节并打印出来-但我们从未看到消息!你能看出原因吗?

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

int main() {
    int fd[2];
    pipe(fd);
    //You must read from fd[0] and write from fd[1]
    printf("Reading from %d, writing to %d\n", fd[0], fd[1]);

    pid_t p = fork();
    if (p > 0) {
        /* I have a child therefore I am the parent*/
        write(fd[1],"Hi Child!",9);

        /*don't forget your child*/
        wait(NULL);
    } else {
        char buf;
        int bytesread;
        // read one byte at a time.
        while ((bytesread = read(fd[0], &buf, 1)) > 0) {
            putchar(buf);
        }
    }
    return 0;
}

父进程将字节H,i,(空格),C...!发送到管道中(如果管道已满,可能会阻塞)。子进程开始逐个字节读取管道。在上面的情况下,子进程将读取并打印每个字符。但它永远不会离开 while 循环!当没有字符可读时,它会简单地阻塞并等待更多。

调用putchar写出字符,但我们从未刷新stdout缓冲区。也就是说,我们已经将消息从一个进程传输到另一个进程,但它还没有被打印出来。要查看消息,我们可以刷新缓冲区,例如fflush(stdout)(或者如果输出是到终端,则printf("\n"))。更好的解决方案还可以通过检查消息结束标记来退出循环,

        while ((bytesread = read(fd[0], &buf, 1)) > 0) {
            putchar(buf);
            if (buf == '!') break; /* End of message */
        }

当子进程退出时,消息将被刷新到终端。

想要使用 printf 和 scanf 与管道吗?使用 fdopen!

POSIX 文件描述符是简单的整数 0,1,2,3...在 C 库级别,C 用缓冲区和有用的函数如 printf 和 scanf 包装这些,所以我们可以轻松地打印或解析整数、字符串等。如果你已经有了一个文件描述符,那么你可以使用fdopen将其自己“包装”成一个 FILE 指针:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main() {
    char *name="Fred";
    int score = 123;
    int filedes = open("mydata.txt", "w", O_CREAT, S_IWUSR | S_IRUSR);

    FILE *f = fdopen(filedes, "w");
    fprintf(f, "Name:%s Score:%d\n", name, score);
    fclose(f);

对于写入文件来说,这是不必要的-只需使用fopen,它与openfdopen相同。但是对于管道,我们已经有了一个文件描述符-所以现在是使用fdopen的好时机!

这里有一个使用管道的完整例子,几乎可以工作!你能发现错误吗?提示:父进程从未打印任何内容!

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    int fh[2];
    pipe(fh);
    FILE *reader = fdopen(fh[0], "r");
    FILE *writer = fdopen(fh[1], "w");
    pid_t p = fork();
    if (p > 0) {
        int score;
        fscanf(reader, "Score %d", &score);
        printf("The child says the score is %d\n", score);
    } else {
        fprintf(writer, "Score %d", 10 + 10);
        fflush(writer);
    }
    return 0;
}

请注意,(未命名的)管道资源将在子进程和父进程都退出后消失。在上面的例子中,子进程将从管道发送字节,父进程将从管道接收字节。然而,从未发送换行符,因此fscanf将继续请求字节,因为它正在等待行结束,即它将永远等待!修复方法是确保我们发送一个换行符,这样fscanf将返回。

change:   fprintf(writer, "Score %d", 10 + 10);
to:       fprintf(writer, "Score %d\n", 10 + 10);

那我们也需要fflush吗?

是的,如果你希望你的字节立即发送到管道中!在本课程开始时,我们假设文件流始终是行缓冲,即 C 库每次发送换行符时都会刷新其缓冲区。实际上,这只对终端流有效-对于其他文件流,C 库尝试通过仅在其内部缓冲区满或文件关闭时刷新来提高性能。

我什么时候需要两个管道?

如果你需要异步地向子进程发送和接收数据,那么需要两个管道(每个方向一个)。否则,子进程将尝试读取自己的数据,这些数据是为父进程准备的(反之亦然)!

关闭管道的陷阱

当没有进程在监听时,进程会收到信号 SIGPIPE!来自 pipe(2)手册页-

If all file descriptors referring to the read end of a pipe have been closed,
 then a write(2) will cause a SIGPIPE signal to be generated for the calling process. 

提示:注意只有写入者(不是读取者)可以使用此信号。为了通知读取者写入者正在关闭管道的端口,你可以写入自己的特殊字节(例如 0xff)或消息("再见!"

这里有一个捕捉这个信号的例子,但不起作用!你能看出原因吗?

#include <stdio.h>
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void no_one_listening(int signal) {
    write(1, "No one is listening!\n", 21);
}

int main() {
    signal(SIGPIPE, no_one_listening);
    int filedes[2];

    pipe(filedes);
    pid_t child = fork();
    if (child > 0) { 
        /* I must be the parent. Close the listening end of the pipe */
        /* I'm not listening anymore!*/
        close(filedes[0]);
    } else {
        /* Child writes messages to the pipe */
        write(filedes[1], "One", 3);
        sleep(2);
        // Will this write generate SIGPIPE ?
        write(filedes[1], "Two", 3);
        write(1, "Done\n", 5);
    }
    return 0;
}

上面代码中的错误是仍然有一个管道的读取者!子进程仍然保持着管道的第一个文件描述符,并记住规范?所有读取者必须关闭。

在分叉时,*关闭子进程和父进程中每个管道的不必要(未使用)端口是常见做法。例如,父进程可能关闭读取端口,子进程可能关闭写入端口(如果有两个管道,则反之亦然)

是什么填满了管道?当管道变满时会发生什么?

当写入者向管道写入过多而读者没有读取时,管道会被填满。当管道变满时,所有写入都会失败,直到发生读取。即使在这种情况下,如果管道还有一点空间但不足以容纳整个消息,写入也可能部分失败。

为了避免这种情况,通常有两种方法。要么增加管道的大小。或者更常见的是,修复你的程序设计,使得管道不断被读取。

管道是否进程安全?

是的!管道写入是原子的,直到管道的大小。这意味着如果两个进程尝试写入同一个管道,内核会使用管道的内部互斥锁来锁定,进行写入,然后返回。唯一需要注意的是当管道即将变满时。如果两个进程尝试写入,而管道只能满足部分写入,那么该管道写入就不是原子的--要小心!

管道的生命周期

无名管道(到目前为止我们见过的那种)存在于内存中(不占用任何磁盘空间),是一种简单高效的进程间通信(IPC)形式,对于流数据和简单消息非常有用。一旦所有进程关闭,管道资源就会被释放。

使用mkfifo创建命名管道是无名管道的一种替代方法。

命名管道

我如何创建命名管道?

从命令行:mkfifo 从 C 语言:int mkfifo(const char *pathname, mode_t mode);

你给它路径名和操作模式,它就准备好了!命名管道在磁盘上不占用空间。当操作系统告诉你有一个命名管道时,它实际上是在告诉你它会创建一个指向命名管道的无名管道,就是这样!没有额外的魔法。这只是为了编程方便,如果进程在没有分叉的情况下启动(这意味着无法为无名管道的子进程获取文件描述符)。

为什么我的管道挂起?

在命名管道上的读写会一直挂起,直到至少有一个读者和一个写者,记住这一点

1$ mkfifo fifo
1$ echo Hello > fifo
# This will hang until I do this on another terminal or another process
2$ cat fifo
Hello

当在命名管道上调用任何open时,内核会阻塞,直到另一个进程调用相反的 open。也就是说,echo 调用open(.., O_RDONLY),但是它会阻塞,直到 cat 调用open(.., O_WRONLY),然后程序才被允许继续。

命名管道的竞争条件。

以下程序有什么问题?

//Program 1

int main(){
    int fd = open("fifo", O_RDWR | O_TRUNC);
    write(fd, "Hello!", 6);
    close(fd);
    return 0;
}

//Program 2
int main() {
    char buffer[7];
    int fd = open("fifo", O_RDONLY);
    read(fd, buffer, 6);
    buffer[6] = '\0';
    printf("%s\n", buffer);
    return 0;
}

这可能永远不会打印 hello,因为存在竞争条件。由于你在第一个进程中以两种权限打开了管道,open 不会等待读者,因为你告诉操作系统你是读者!有时它看起来像是工作的,因为代码的执行看起来像这样。

进程 1进程 2
open(O_RDWR) & write()
open(O_RDONLY) & read()
close() & exit()
print() & exit()

有时候不会

进程 1进程 2
open(O_RDWR) & write()
close() & exit()(命名管道被销毁)
(无限期阻塞)open(O_RDONLY)

文件,第一部分:处理文件

两种类型的文件

在 Linux 上,有两种文件抽象。第一种是 Linux 的fd级别抽象,这意味着你可以使用

  • 打开

  • 关闭

  • lseek

  • fcntl ...

等等。Linux 接口非常强大和富有表现力,但有时我们需要可移植性(例如,如果我们在为 Mac 或 Windows 编写代码)。这就是 C 的抽象发挥作用的地方。在不同的操作系统上,C 使用低级函数来创建一个文件的包装器,你可以在任何地方使用,这意味着 Linux 上的 C 使用上述调用。C 有以下几种

  • fopen

  • freadfgetc/fgetsfscanf

  • fwritefprintf

  • fclose

  • fflush

但你无法获得 Linux 通过系统调用给你的表达能力,你可以在它们之间进行转换,使用int fileno(FILE* stream)FILE* fdopen(int fd...)

另一个重要的方面要注意的是 C 文件是缓冲的,这意味着它们的内容可能不会立即被写入。你可以通过 C 选项来改变这一点。

我怎么知道文件有多大?

对于小于 long 的大小的文件,使用 fseek 和 ftell 是一种简单的方法来实现这一点:

移动到文件的末尾并找出当前位置。

fseek(f, 0, SEEK_END);
long pos = ftell(f);

这告诉我们文件中的当前位置,以字节为单位 - 即文件的长度!

fseek也可以用来设置绝对位置。

fseek(f, 0, SEEK_SET); // Move to the start of the file 
fseek(f, posn, SEEK_SET);  // Move to 'posn' in the file.

所有父进程或子进程中的未来读写操作都将遵守这个位置。请注意,从文件中写入或读取将改变当前位置。

查看 fseek 和 ftell 的 man 页面以获取更多信息。

但尽量不要这样做

注意:这在通常情况下是不推荐的,因为 C 语言有一个怪癖。这个怪癖是 long 只需要4 个字节大,这意味着 ftell 能返回的最大大小略小于 2GB(而我们现在知道我们的文件可能是数百 GB 甚至分布式文件系统上的 TB)。我们应该怎么办呢?使用stat!我们将在后面的部分介绍 stat,但这里有一些代码可以告诉你文件的大小

struct stat buf;
if(stat(filename, &buf) != -1){
    return -1;
}
return (ssize_t)buf.st_size;

buf.st_size 的类型是 off_t,对于极大的文件来说足够大。

如果子进程使用fcloseclose关闭文件流会发生什么?

关闭文件流对每个进程都是独特的。其他进程可以继续使用自己的文件句柄。记住,当创建一个子进程时,甚至文件的相对位置也会被复制过去。

文件的 mmap 怎么样?

mmap 的一个常见用途是将文件映射到内存。这并不意味着文件会立即被 malloc 到内存中。以下面的代码为例。

int fd = open(...); //File is 2 Pages
char* addr = mmap(..fd..);
addr[0] = 'l'; 

内核可能会说:“好的,我看到你想要将文件映射到内存中,所以我将在你的地址空间中保留一些文件长度的空间”。这意味着当你写入 addr[0]时,实际上是在文件的第一个字节上写入。内核实际上也可以进行一些优化。它可能一次只加载一页,因为如果文件有 1024 页;你可能只访问 3 或 4 页,这样加载整个文件就是浪费时间(这就是为什么页面错误是如此强大的原因!它们让操作系统控制你使用文件的程度)。

对于每个 mmap

记住,一旦你完成了mmap,你需要munmap告诉操作系统你不再使用分配的页面,这样操作系统可以将它写回磁盘,并在以后需要 malloc 时将地址还给你。

调度,第一部分:调度进程

考虑调度。

CPU 调度是有效地选择要在系统 CPU 核心上运行的进程的问题。在繁忙的系统中,准备运行的进程将比 CPU 核心多,因此系统内核必须评估应该调度哪些进程在 CPU 上运行,以及应该将哪些进程放在就绪队列中以便稍后执行。

多线程和多 CPU 核心的额外复杂性被认为是对这个初始阐述的干扰,因此在这里被忽略。

对于非母语的人来说,另一个需要注意的是“时间”一词的双重含义:单词“时间”可以在时钟和经过的持续时间上下文中使用。例如,“第一个进程的到达时间是上午 9:00。”和“算法的运行时间为 3 秒。”

调度如何衡量,哪种调度程序最好?

调度影响系统的性能,特别是系统的延迟和吞吐量。吞吐量可以通过系统值来衡量,例如 I/O 吞吐量-每秒写入的字节数,或者每单位时间可以完成的小进程数量,或者使用更高级的抽象,例如每分钟处理的客户记录数量。延迟可以通过响应时间(进程开始发送响应之前的经过时间)或等待时间或周转时间(完成任务所经过的时间)来衡量。不同的调度程序提供不同的优化权衡,可能适用于所需的使用-并非所有可能的环境和目标都有最佳的调度程序。例如,“最短作业优先”将最小化所有作业的总等待时间,但在交互(UI)环境中,最好是最小化响应时间(以牺牲一些吞吐量),而 FCFS 似乎直观公平且易于实现,但受到车队效应的影响。

到达时间是什么?

进程首次到达就绪队列并准备开始执行的时间。如果 CPU 空闲,到达时间也将是执行的开始时间。

什么是抢占?

没有抢占,进程将运行,直到无法再利用 CPU。例如,以下条件将从 CPU 中移除进程,并使 CPU 可供其他进程调度:进程因信号终止,被阻塞等待并发原语,或正常退出。因此,一旦进程被调度,即使另一个具有较高优先级(例如更短的作业)的进程出现在就绪队列上,它也将继续运行。

通过抢占,如果就绪队列中添加了一个更可取的进程,现有进程可能会立即被移除。例如,假设在 t=0 时,使用最短作业优先调度程序有两个进程(P1 P2),执行时间分别为 10 和 20 毫秒。P1 被调度。P1 立即创建一个新的进程 P3,执行时间为 5 毫秒,将其添加到就绪队列。如果没有抢占,P3 将在 10 毫秒后运行(在 P1 完成后)。有了抢占,P1 将立即从 CPU 中驱逐,并放回就绪队列,CPU 将执行 P3。

哪些调度程序会导致饥饿?

任何使用优先级形式的调度程序都可能导致饥饿,因为较早的进程可能永远不会被调度运行(分配 CPU)。例如,使用 SJF,如果系统继续有许多短作业要调度,较长的作业可能永远不会被调度。这一切取决于调度程序的类型。

为什么进程(或线程)会被放置在就绪队列上?

当进程能够使用 CPU 时,进程将被放置在就绪队列上。一些例子包括:

  • 进程被阻塞等待存储或套接字的“读”完成,现在数据可用。

  • 一个新进程已经创建并准备好开始。

  • 一个进程线程被阻塞在同步原语(条件变量、信号量、互斥锁)上,但现在可以继续。

  • 一个进程被阻塞,等待系统调用完成,但已经传递了一个信号,信号处理程序需要运行。

考虑线程时可以生成类似的例子。

效率的度量

开始时间是进程的挂钟开始时间(CPU 开始处理它)结束时间是进程的结束挂钟(CPU 完成进程)运行时间是所需的 CPU 时间总量到达时间是进程进入调度程序的时间(CPU 可能不开始处理它)

什么是“周转时间”?

从进程到达到结束的总时间。

周转时间=结束时间-到达时间

什么是“响应时间”?

从进程到达到 CPU 实际开始处理它所需的总延迟(时间)。

响应时间=开始时间-到达时间

什么是“等待时间”?

等待时间是等待时间,即进程在就绪队列上的总时间。一个常见的错误是认为它只是在就绪队列中的初始等待时间。

如果一个不进行 I/O 的 CPU 密集型进程需要 7 分钟的 CPU 时间才能完成,但需要 9 分钟的挂钟时间才能完成,我们可以得出结论,它在就绪队列中等待了 2 分钟。在这 2 分钟内,进程准备好运行,但没有分配 CPU。作业等待的时间是 2 分钟,无论作业等待的时间是什么时候。

等待时间=(结束时间-到达时间)-运行时间

什么是车队效应?

“车队效应是指 I/O 密集型进程不断积压,等待占用 CPU 的 CPU 密集型进程。这导致 I/O 性能不佳,即使对于 CPU 需求很小的进程也是如此。”

假设 CPU 当前被分配给一个 CPU 密集型任务,并且有一组 I/O 密集型进程在就绪队列中。这些进程只需要很少的 CPU 时间,但它们无法继续进行,因为它们正在等待 CPU 密集型任务从处理器中移除。这些进程会饿死,直到 CPU 绑定的进程释放 CPU。但 CPU 很少会被释放(例如,在 FCFS 调度程序的情况下,我们必须等到进程因 I/O 请求而被阻塞)。I/O 密集型进程现在可以满足它们的 CPU 需求,因为它们的 CPU 需求很小,而 CPU 又被分配给 CPU 密集型进程。因此,整个系统的 I/O 性能会因所有进程的 CPU 需求饥饿而受到间接影响。

这种效应通常在 FCFS 调度程序的情况下讨论,但是循环调度程序也可能出现长时间量的车队效应。

Linux 调度

截至 2016 年 2 月,Linux 默认使用完全公平调度程序进行 CPU 调度,使用 I/O 调度的“BFQ”进行预算公平调度。适当的调度对吞吐量和延迟有重大影响。延迟对交互式和软实时应用程序特别重要,例如音频和视频流。有关更多信息,请参见此处的讨论和比较基准[lkml.org/lkml/2014/5/27/314]。

这是 CFS 的调度方式

  • CPU 使用进程的虚拟运行时间(运行时间/优先级值)和睡眠公平性(如果进程正在等待某些东西,当它完成等待时给它 CPU)创建红黑树。

  • (优先级值是内核给予某些进程优先级的方式,值越低,优先级越高)

  • 内核根据此度量选择最低的度量,并安排该进程作为下一个运行,将其从队列中移除。由于红黑树是自平衡的,此操作保证为O(log(n))O(log(n))(选择最小进程是相同的运行时间)

尽管它被称为公平调度器,但存在相当多的问题。

  • 被调度的进程组可能负载不平衡,因此调度器会大致分配负载。当另一个 CPU 空闲时,它只能查看组调度的平均负载,而不是单独的核心。因此,只要平均负载正常,空闲的 CPU 可能不会接手一个长时间运行的 CPU 的工作。

  • 如果一组进程在非相邻的核心上运行,那么就会出现问题。如果两个核心的距离超过一个跳跃,负载平衡算法甚至不会考虑那个核心。这意味着如果一个 CPU 空闲,而另一个 CPU 的工作量超过一个跳跃的距离,它不会接手这个工作(可能已经修复)。

  • 线程在一组核心上休眠后,醒来时只能在它休眠的核心上被调度。如果这些核心现在很忙,那么就会出现问题。

调度,第二部分:调度进程:算法

一些著名的调度算法是什么?

对于所有的例子,

进程 1:运行时间 1000 毫秒

进程 2:运行时间 2000 毫秒

进程 3:运行时间 3000 毫秒

进程 4:运行时间 4000 毫秒

进程 5:运行时间 5000 毫秒

最短作业优先(SJF)

  • P1 到达:0 毫秒

  • P2 到达:0 毫秒

  • P3 到达:0 毫秒

  • P4 到达:0 毫秒

  • P5 到达:0 毫秒

所有进程在开始时到达,调度程序安排具有最短总 CPU 时间的作业。明显的问题是,这个调度程序需要在运行程序之前知道这个程序将在未来的时间内运行多长时间。

技术说明:实际的 SJF 实现不会使用进程的总执行时间,而是使用突发时间(包括进程不再准备运行之前的未来计算执行的总 CPU 时间)。可以通过使用基于先前突发时间的指数衰减加权滚动平均值来估计预期的突发时间,但是为了简化讨论,我们将在这里使用进程的总运行时间作为突发时间的代理。

优点

  • 较短的作业往往会先运行

缺点

  • 需要算法是全知的

抢占式最短作业优先(PSJF)

抢占式最短作业优先类似于最短作业优先,但如果新作业的运行时间比进程的剩余运行时间短,则运行该作业。(如果像我们的例子一样相等,我们的算法可以选择)。调度程序使用进程的总运行时间,如果要使用最短剩余时间,那就是 PSJF 的一个变体,称为最短剩余时间优先。

  • P2 在 0 毫秒

  • P1 在 1000 毫秒

  • P5 在 3000 毫秒

  • P4 在 4000 毫秒

  • P3 在 5000 毫秒

我们的算法是这样的。它运行 P2,因为它是唯一要运行的东西。然后 P1 在 1000 毫秒时进来,P2 运行了 2000 毫秒,所以我们的调度程序会抢占性地停止 P2,并让 P1 一直运行(这完全取决于算法,因为时间相等)。然后,P5 进来了--因为没有进程在运行,调度程序将运行进程 5。P4 进来了,因为运行时间相等于 P5,调度程序停止 P5 并运行 P4。最后 P3 进来,抢占 P4,并运行到完成。然后 P4 运行,然后 P5 运行。

优点

  • 确保较短的作业先运行

缺点

  • 需要再次知道运行时间

**注意:**出于历史原因,该算法比较总运行时间而不是剩余运行时间。如果要考虑剩余时间,将使用抢占式最短剩余时间优先(PSRTF)。

先来先服务(FCFS)

  • P2 在 0 毫秒

  • P1 在 1000 毫秒

  • P5 在 3000 毫秒

  • P4 在 4000 毫秒

  • P3 在 5000 毫秒

进程按到达顺序进行调度。FCFS 的一个优点是调度算法很简单:就绪队列只是一个 FIFO(先进先出)队列。FCFS 遭受护航效应的影响。

这里 P2 到达,然后是 P1 到达,然后是 P5,然后是 P4,然后是 P3。您可以看到 P5 的护航效应。

优点

  • 简单实现

缺点

  • 长时间运行的进程可能会阻塞所有其他进程

轮转法(RR)

进程按照它们在就绪队列中的到达顺序进行调度。但是在一个小的时间步长之后,正在运行的进程将被强制从运行状态中移除,并放回就绪队列。这确保了长时间运行的进程不能使所有其他进程无法运行。进程在返回就绪队列之前可以执行的最长时间称为时间量子。在时间量子较大的极限情况下(时间量子长于所有进程的运行时间),轮转法将等效于 FCFS。

  • P1 到达:0 毫秒

  • P2 到达:0 毫秒

  • P3 到达:0 毫秒

  • P4 到达:0 毫秒

  • P5 到达:0 毫秒

量子=1000 毫秒

在这里,所有进程同时到达。P1 运行 1 个量子,然后完成。P2 运行一个量子;然后,它被停止给 P3。在所有其他进程运行一个量子后,我们循环回到 P2,直到所有进程都完成。

优点

  • 确保公平的概念

缺点

  • 大量进程=大量切换

优先级

进程按优先级值的顺序进行调度。例如,导航进程可能比日志记录进程更重要执行。

IPC 复习问题

主题

虚拟内存页表 MMU/TLB 地址转换页面错误帧/页单级与多级页表计算多级页表的偏移管道管道读写端写入零读取管道从零写入管道命名管道和无命名管道缓冲区大小/原子性调度算法效率衡量

问题

  • 虚拟内存是什么?

  • 以下是什么以及它们的目的是什么?

    • 翻译旁路缓冲区

    • 物理地址

    • 内存管理单元。多级页表。帧号。页号和页偏移。

    • 脏位

    • NX 位

  • 什么是页表?物理帧呢?页面是否总是需要指向物理帧?

  • 什么是页面错误?有哪些类型?什么时候会导致段错误?

  • 单级页表有什么优点?缺点?多级表呢?

  • 多级表在内存中是什么样子的?

  • 如何确定页面偏移中使用了多少位?

  • 给定 64 位地址空间,4kb 页和帧,以及 3 级页表,虚拟页号 1,VPN2,VPN3 和偏移分别有多少位?

  • 什么是管道?如何创建管道?

  • SIGPIPE 是在什么时候传递给进程的?

  • 在什么条件下调用管道上的 read()会阻塞?在什么条件下 read()会立即返回 0?

  • 命名管道和无命名管道之间有什么区别?

  • 管道是线程安全的吗?

  • 编写一个使用 fseek 和 ftell 来用'X'替换文件的中间字符的函数

  • 编写一个创建管道并使用 write 发送 5 个字节“HELLO”到管道的函数。返回管道的读文件描述符。

  • 当您 mmap 文件时会发生什么?

  • 为什么不建议使用 ftell 获取文件大小?应该如何替代?

  • 什么是调度?

  • 周转时间是什么?响应时间?等待时间?

  • 什么是护航效应?

  • 哪些算法平均具有最佳的周转/响应/等待时间