操作系统笔记(10)-并发

简介

  • 操作系统除了支持多进程之外,对单个运行的进程也提供了多任务运行的支持,即 线程,每个 线程 类似于独立的进程,与多进程不同的是,它们之间 共享地址空间,从而能够访问相同的数据。另一个不同的地方在于 ,单线程中只有一个栈而多线程中每个线程都有一个栈

  • 每个线程都有自己的一组用于计算的寄存器,因此,如果在单核机器上运行两个线程,从一个线程切换到另一个线程时,必定发生 上下文切换

  • 线程的 上下文切换 类似于进程的 上下文切换,需要一个或多个 线程控制块(TCB) 来保存每个线程的状态

共享数据的难点

  • 使用 多线程 访问共享数据时,很容易产生一个问题就是当多个线程访问同一个地址空间时会出现 竞态,到底谁先访问谁后访问,很多时候可能会产生不同的结果,也因此有了一些并发术语:

    • 临界区(critical section) : 是访问共享资源的一段代码,资源通常是一个变量或数据结构

    • 竞态条件(race condition) : 出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的结果

    • 不确定性(indeterminate) : 程序由一个或多个竞态条件组成,程序的输出因运行而异,具体取决于哪些线程在何时运行。这导致结果是不确定的

    • 互斥执行(mutual exclusion) : 为了解决这个问题,需要一些互斥操作,保证当一个线程访问临界区时另外的线程不能进入

POSIX 线程 API

#include <pthread.h>

// 1. 创建一个线程
// pthread_t 是 unsigned long int 类型
// attr 设置线程属性, 一般为 NULL
// start_routine 是函数指针, 指向线程将要执行的任务
// arg 是函数参数
int pthread_create(pthread_t* thread, const pthread_attr_t* attr,
                   void* (*start_routine)(void*), void* arg);


// 2.线程结束(执行完之后不会反回到调用者, 而且永远不会失败)
void pthread_exit(void* retval);

// 3.等待其它线程结束
int pthread_join(pthread_t thread, void** retval);

// 4.异常终止一个线程
int pthread_cancel(pthread_t thread);
  1. 一个用户可以打开的线程数量不能超过软资源限制。此外,系统上所有用户能创建的线程总数也不得超过 /proc/sys/kernel/threads-max 内核参数所定义的值

  2. 线程函数在结束时最好调用此函数,以确保安全、干净地退出

  3. 注意这里传入的不是指针

  4. 该函数成功时返回0,失败则返回错误码

测试

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

typedef struct myarg_t {
    int a;
    int b;
} myarg_t;

typedef struct myret_t {
    int x;
    int y;
} myret_t;

void* work(void *arg) {
    myarg_t *m = (myarg_t*)arg;
    printf("%d %d\n", m->a, m->b);

    // 注意这里不能返回栈上定义的值, 因为执行完后会释放线程栈
    myret_t *r = (myret_t*)malloc(sizeof(myret_t));
    r->x = 1, r->y = 2;
    return (void*)r;
}

int main(int argc, char* argv[]) {
    pthread_t t;
    myarg_t arg;
    arg.a = 10, arg.b = 20;
    myret_t *ret;
    pthread_create(&t, NULL, work, (void*)&arg);
    pthread_join(t, (void**)&ret);
    printf("returned %d %d\n", ret->x, ret->y);
    free(ret);
    return 0;
}

// 编译指令:gcc -o p1 p1.c -lpthread

// output:
// 10 20 
// returned 1 2

POSIX 互斥锁 API

  • 互斥锁是为了帮我我们正确地访问 临界区 而创建的,它保证当多个线程访问某一临界区时,只有获得锁的那个线程进入
#include <pthread.h>

// 初始化互斥锁
int pthread_mutex_init(pthread_mutex_t* mutex, const pthread_mutexattr_t* mutexattr);

// 清理互斥锁的数据结构
int pthread_mutex_destroy(pthread_mutex_t* mutex);

// 尝试获得锁,若此时其它线程获得,就阻塞此线程
int pthread_mutex_lock(pthread_mutex_t* mutex);

// 尝试获得锁,若此时其它线程获得,就直接返回
int pthread_mutex_trylock(pthread_mutex_t* mutex);

// 释放已经获得的锁,让其它线程有机会获得
int pthread_mutex_unlock(pthread_mutex_t* mutex);

测试

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

int counter = 0;

pthread_mutex_t m;

void* work(void *arg) {
    for (int i = 0; i < 10000; i++) {
        pthread_mutex_lock(&m);
        ++counter;                  // 临界区
        pthread_mutex_unlock(&m);
    }
    return NULL;
}


int main(int argc, char* argv[]) {
    pthread_t p1, p2;
    pthread_mutex_init(&m, NULL);

    pthread_create(&p1, NULL, work, NULL);
    pthread_create(&p2, NULL, work, NULL);

    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    printf("counter : %d\n", counter);
    pthread_mutex_destroy(&m);
    return 0;
}

// output:
// counter : 20000

POSIX 条件变量 API

  • 如果一个线程在等待另一个线程继续执行某些操作,条件变量就很有用
#include <pthread.h>

// 初始化条件变量
int pthread_cond_init(pthread_cond_t* cond, const pthread_condattr_t* cond_attr);

// 释放条件变量数据结构
int pthread_cond_destroy(pthread_cond_t* cond);

// 相当于c++的 notify_all(), 唤醒所有睡眠的线程
int pthread_cond_broadcast(pthread_cond_t* cond);

// 相当于c++的 notify_one(), 唤醒一个睡眠的线程
int pthread_cond_signal(pthread_cond_t* cond);

// (调用之前保证获取锁) 使线程睡眠, 然后释放锁,等待唤醒,唤醒后重新获得锁
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex);

测试

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

int ready = 0;
pthread_mutex_t m;
pthread_cond_t cond;

void* Wait(void *arg) {
    pthread_mutex_lock(&m);

    int flag = 0;

    while (ready == 0) {
        printf("释放锁并等待线程被唤醒...\n");
        pthread_cond_wait(&cond, &m);
        flag = 1;
        printf("线程被唤醒,此时重新获得锁\n");
    }

    if (flag == 1) {
        printf("Wait 线程先执行!\n");
    }
    else {
        printf("Weak 线程先执行!\n");
    }

    pthread_mutex_unlock(&m);
    return NULL;
}

void* Weak(void *arg) {
    pthread_mutex_lock(&m);
    ready = 1;
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&m);
}

int main(int argc, char* argv[]) {
    pthread_t p1, p2;

    pthread_mutex_init(&m, NULL);
    pthread_cond_init(&cond, NULL);

    // 注意哪个线程先执行是很重要的
    pthread_create(&p1, NULL, Wait, NULL);
    pthread_create(&p2, NULL, Weak, NULL);

    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    pthread_mutex_destroy(&m);
    pthread_cond_destroy(&cond);
    return 0;
}

建议

  • 保持简洁 :线程之间的锁和信号的代码应该尽可能简洁

  • 让线程交互减到最少 :尽量减少线程之间的交互

  • 初始化锁和条件变量 :未初始化可能会导致奇怪的结果

  • 检查返回值 :应该检查 API 的返回值来确定函数是否调用成功

  • 注意传给线程的参数和返回值 :如果传递在栈上分配的变量的引用,就会出问题

  • 每个线程都有自己的栈 :线程局部变量是线程私有的,要在线程之间共享数据,值要在堆或其他全局可访问的位置

  • 线程之间总是通过条件变量发送信号 :切记不要标记变量(自旋)来同步

  • 多查手册 :关于 API 的细节,可以查询 man 手册来了解