计算机体系结构——并行程序和线程

计算机体系结构——并行程序和线程

线程基本概念

在每一个进程中,都保存了一个进程上下文用来保存该进程的所有运行信息。进程上下文分为:

  • 程序上下文,保存当前程序的执行信息,包括寄存器的值等等。
  • 内核上下文,保存当前进程的信息,例如进程 ID 以及虚拟内存结构打开的文件等等。

我们可以将程序上下文分成多个线程上下文,每个线程上下文除了包含程序上下文中的信息之外,还应该包含自己独立的栈帧,这样才能做的并行处理。

线程是进程内的并行处理单元,进程是系统内的并行处理单元,线程是控制流的最小单元。

线程和进程有以下区别:

  1. 线程调度算法比进程简单,因此线程调度比进程快。
  2. 线程没有像进程那样的父子关系,所有线程都是平等的。
  3. 存在一个主线程,程序默认执行主线程。一个线程可以创建另外一个线程。
  4. 线程和线程之间可以直接共享数据。

由于各个系统厂商提供的线程接口不同,导致了不同线程的 API ,但是 POSIX 提出了一个通用线程接口的库叫做 Pthread 。我们可以使用 Pthread 库进行跨平台。

线程流控制

线程的创建

#include<pthread.h>

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
void *thread(void *vargp);

pthread_create 函数用于创建线程,第一个参数表明要保存的新的进程信息所在的位置的指针,第二个参数表明新的线程的行为,第三个参数传递要执行的线程的函数,第四个传递要传递给新线程的参数。

每个线程函数有一个通用指针作为参数,有一个通用指针作为返回值。

pthread_t pthread_self(void);

该函数返回当前线程的信息。

终止线程

线程的终止一种是隐式终止,即线程函数返回。另外一种调用

void pthread_exit(void* retval);

显式终止当前线程。

另外如果想终止其他线程,可以调用:

int pthread_cancel(pthread_t thread);

等待收割线程

使用

int pthread_join(pthread_t thread, void **retval);

等待一个函数的结束,次函数以阻塞的方式运行。

分发线程

一个线程要么是可等待的,要么是可分发的。默认创建的线程都是可等待的线程,即它的内存资源必须被其他线程显式等待收割。

一个可分发线程的内存回收由系统负责,即线程结束后系统自动回收,不需要其他线程进行显式回收。

使用

int pthread_detach(pthread_t tid);

将一个线程标记位可分发线程,例如在一个高性能 Web 服务器中各个线程是独立的,可以将其标记位可分发线程。

线程数据共享

我们说一个变量是被共享的,当且仅当这个变量被多个线程所引用。

总之,每个线程上下文中的寄存器数据是不被共享的,但是每个线程共用一套虚拟内存方案,因此公共虚拟内存是被共享的,特别的,由于每个线程的函数栈帧独立存在,因此非静态局部变量也通常不被共享。

线程同步和信号量

共享变量是方便的,但是有时候也会造成许多同步错误。

顺序一致性

线程的执行遵循顺序一致性原理,也就是说,通常操作的最小单位是一条汇编语句称为原子操作,假设一个函数按序执行 U U U L L L R R R 三条原子操作,存在两个线程执行相同的函数,也就是说仅分别保证 U 1 L 1 R 1 U_1L_1R_1 U1L1R1 U 2 L 2 R 2 U_2L_2R_2 U2L2R2 的顺序,但是两者结合的顺序是可以穿插进行的,不同的穿插顺序造成了不同的执行结果,我们没有办法预测计算机以什么顺序执行线程,这就造成了线程同步错误。

线程流程图

我们可以通过描绘线程流程图的方式直观的表述原子操作执行顺序不同带来的影响。

一个线程流程图是一个 n n n 维笛卡尔坐标系,每一个坐标轴代表了一个线程的指令时间轴。

线程流程图

如上图,两条轨迹代表了两个线程执行的不同方式,因为线程遵循顺序一致性,因此每一条合法的轨迹在每个轴上的投影轨迹都必须是一条从左到右的直线且没有折返。左下角的原点称为起点,右上角的原点称为终点。

其中 L 和 U 和 R 称为修改指令,指的是操作共享变量的指令,这些指令在不同轴相交的空间构成了不安全空间,任何和不安全空间相交的轨迹都是不安全的轨迹,会发生未知无法预期的结果,相反,和不安全空间不相交的轨迹都是安全的轨迹,结果均符号预期。

Dijkstra 提出使用信号量对线程进行同步,使得线程的执行绕开不安全空间。

信号量

信号量是一个个数据结构,包含一个整型数据 s s s 和两个操作 P P P V V V 。其中 s s s 必须是被线程共享的变量,例如全局变量。

两个操作被定义为:

  • P(s) : while(s <= 0); s--;
  • V(s) : s++;

P(s) 用于等待 s s s 变成正整数然后再自减, V(s) 用于自增变量。

合理结合两个操作可以使得线程绕开非安全区:

信号量

上图每个节点都标记了每个状态的 s 的值。可见,程序无法进入 s s s 为负值的区域,称为截止区,截止区包含了非安全区,使得程序无法进入非安全区。

值得注意的是不管是 P(s) 还是 V(s) 都是原子操作,通常由操作系统或者是 CPU 指令实现。

POSIX 中的信号量

Posix 中定义了许多函数用于操作信号量。

#include <semaphore.h>
int sem_init(sem_t *sem,int pshared,unsigned int value);

该函数用于创建并注册信号量,第一个函数是给出信号量变量的地址,第二个参数用于线程时恒为 0,第三个参数是信号量变量的初始值。

int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);

分别是 Posix 中的 P(s) 操作和 V(s) 操作。

生成者-消费者模型

线程一个经典的应用就是生成者-消费者模型,一个线程负责向缓冲区中存放数据,另一个线程从缓冲区中拿出数据。

生成者-消费者模型应用于播放器应用,解码器负责向缓冲区中存放数据,渲染器缓冲区中拿出数据渲染到屏幕上。

在 UI 系统中,系统将事件放入消息队列,程序从消息队列中取出并处理数据。

线程同步和互斥和条件变量

Posix 也提供了另外一种线程同步的方式称为互斥和条件变量。

互斥变量

互斥变量也称互斥锁,是一种特殊的信号量,其 s s s 只能是 0 或 1 ,称为二进制信号量。

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *mutex,pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

第一个函数用于初始化互质变量,第二个参数恒为 NULL 。也可通过 pthread_mutex_t mutex = PTHREAD_MUTEX_INITALIZER; 初始化。

第二个函数是 P(s) 操作,称为获取锁操作,第三个函数是 V(s) 操作,称为释放锁操作,保证了在同一时刻只有一个线程能够拿到锁。

条件变量

条件变量可以阻塞一个线程取去等待一个显式的信号继续运行。

int pthread_cond_init(pthread_cond_t *cv, pthread_condattr_t *cattr);
int pthread_cond_wait(pthread_cond_t *cv, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cv);
int pthread cond broadcast(pthread cond t *cond);

第一个函数用于初始化条件变量,第二个函数用于阻塞一个线程,需要一个锁进行保证阻塞函数是一个原子操作,第三函数用于发出信号。

其中第二个函数需要搭配锁进行使用:

pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex);
pthread_mutex_unlock(&mutex);

pthread_cond_wait 在进入阻塞之前会释放锁。

注意第三个函数只能唤起一个等待的线程,如果有多个等待的线程,唤哪一个线程是无法预期的。

第四个函数可以唤起所有等待的线程。

线程协同

线程协同是一个条件变量和互斥锁应用的一个例子,如果想让所有的线程执行的速度进行同步使用 barrier 函数。

#include "csapp.h"

static pthread_mutex_t mutex;
static pthread_cond_t cond;

static int nthreads;
static int barriercnt = 0;

void barrier_init(int n)
{
 nthreads = n;
 Pthread_mutex_init(&mutex, NULL);
 Pthread_cond_init(&cond, NULL);
}

void barrier()
{
 Pthread_mutex_lock(&mutex);
 if (++barriercnt == nthreads) {
 barriercnt = 0;
 Pthread_cond_broadcast(&cond);
 }
 else
 Pthread_cond_wait(&cond, &mutex);
 Pthread_mutex_unlock(&mutex);
}

可以看出,当所有线程都执行 barrier 才会继续执行。

超时等待

int pthread cond timedwait(pthread cond t *cond, pthread mutex t *mutex, struct timespec *abstime);

可以设置等待的时间。

线程安全和可重入函数

我们说一个函数是线程安全的,其具备在多个线程下并行调用不会产生非预期的结果。

可重入函数

重入函数是一类特殊的线程安全的函数,他不引用全局贡献变量。因此不需要进行同步。是不是可重入的函数取决于函数本身和调用者。

  • 显式可重入函数。如果参数没有指针类型,并且函数体也不会引用全局共享变量。
  • 隐式可重入函数。允许参数中的指针类型,但是指针指向的不是全局共享变量。

同步错误

尽管在一个系统中都是线程安全的函数,如果不适当使用同步,那么会造成一些不良后果。

竞争

如果多个线程共用一个系统资源,则会产生竞争,竞争会导致系统性能下降。解决竞争的一个方法是为每一个线程都创建一个资源。

死锁

如果两个线程同时持有对方的锁,并同时获取对方的锁,此时会发生死锁:

死锁

例如在上图两个截止区的交叠区域就会出现死锁区,任何进入死锁区的轨迹将不能退出。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值