【并行程序设计导论】第4章:用Pthreads 进行共享内存编程
文章目录
4.1 预备知识
- 线程,也被称为轻量级进程,使用的是
POSIX
线程库,也经常称为Pthreads 线程库。POSIX [ 41] 是一个类Unix 操作系统(如Linux 、Mac OS X) 上的标准库。 - Pthreads 的API 只有在支持POSIX 的系统(Linux 、Mac OS X 、Solaris 、HPUX 等)上才有效。
- Pthreads 是一个
C 语言库
,所以也可以用在C++程序中 - Pthreads 程序和普通串行程序一样,是编译完再运行。
- Pthreads 程序也采用“单程序,多数据"的并行模式,即每个线程都执行同样的线程函数,但可以在线程内用条件转移来获得不同线程有不同功能的效果。
4.2 编译与运行
命令:
$gcc\g++ −g −Wall −o pthread[可执行文件名] pthread.c\pthread.cpp -lpthread /*编译*/
$./pthread[可执行文件名] <number of threads> /*运行*/
4.3 Pthread实现
4.3.1 准备工作
- 头文件
#include <pthread.h>
- 在Pthreads 程序中,全局变量被所有线程所共享,而在函数中声明的局部变量则(通常)由执行该函数的线程所私有。
- 如果多个线程都要运行同一个函数,则每个线程都拥有自己的私有局部变量和函数参数的副本。
- strtol函数:将字符串转化为long int (长整型),在
stdlib.h
中声明
long strtol(
const char* number_p, /* in */
char** end_p, /* out */
int base /*in */
)
函数作用:
它返回由
number_p
所指向的字符串转换得到的长整型数,参数base
是表达这个整数值所用的基(进位计数制)。如果end_p
不是NULL
, 它就指向number_p
字符串中第一个无效字符(非数值字符)。
4.3.2 启动线程
(1) pthread_create函数
函数原型:
int pthread_create (
pthread_t* thread_p, /* out */
const pthread_attr_t* attr_p, /* in */
void* (*start_routine ) ( void ), /* in */
void* arg_p /* in */
)
参数解释:
①pthread_t*
thread_p
指针,指向 pthread_t 对象。⚠️注意,pthread_t 对象不是由pthread_create 函数分配的,必须在调用pthread_create 函数前就为pthread_t 对象分配内存空间。
②const pthread_attr_t*attr_p
不用,只是在函数调用时把 NULL 传递给参数。
③void*(*start_routine ) ( void )
表示该线程将要运行的函数。
④void*arg_p
指针,指向传给函数 start_routine 的参数。
返回值:
用于表示线程调用过程中是否有错,一般忽略返回值
(2)void* thread_function(void* args_p)函数
args_p
可以指向一个列表,该列表包含一个或多个thread_function 函数需要的数值。thread_function
返回的值也可以是一个包含一个或多个值的列表。
4.3.3 运行线程
- 运行main 函数的线程一般称为主线程。
- 在Pthreads 中,程序员不直接控制线程在哪个核上运行。在pthread_create 函数中,没有参数用于指定在哪个核上运行线程。
- 线程的调度是由操作系统来控制的。有些系统(例如,某些Linux 的实现版本)允许程序员指定线程运行在哪个核上,但这些版本是无法移植的。
- 在负载很重的系统上,所有线程可能都运行在同一个核上。事实上,如果线程个数大于核的个数,就会出现多个线程运行在一个核上。
- 当然,如果某个核处于空闲状态,操作系统就会将一个新线程分配给这个核。
4.3.4 停止线程
(1)pthread_join函数
函数原型:
int pthread_join(
pthread_t thread_h, /* in */
void** ret_val_p /* out */
)
参数解释:
ret_val_p
接收任意由pthread_t 对象所关联的那个线程产生的返回值。
- 如果将主线程视为一条线,那么,当调用
pthread_create
时,主线程上会创建一个分支或分叉。 - 对
pthread_create
的多次调用将导致多个分支或分叉。 - 然后,当由
pthread_create
启动的线程终止时,这些分支会并入主线程
4.3.5 示例
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
/* Global variable: accessible to all threads */
int thread_count;
void* Hello(void* rank); /* Thread function */
int main(int argc, char* argv[]) {
long thread; /* Use long in case of a 64−bit system */
pthread_t* thread_handles;
/* Get number of threads from command line */
thread_count = strtol(argv[1], NULL, 10);
thread_handles = malloc (thread_count*sizeof(pthread_t));
for (thread = 0; thread < thread_count; thread++)
pthread_create(&thread_handles[thread], NULL, Hello, (void*) thread);
printf("Hello from the main thread\n");
for (thread = 0; thread < thread_count; thread++)
pthread_join(thread_handles[thread], NULL);
free(thread_handles);
return 0;
} /* main */
void* Hello(void* rank) {
long my_rank = (long) rank;
/* Use long in case of 64−bit system */
printf("Hello from thread %ld of %dn\n", my_rank, thread_count);
return NULL;
} /* Hello */
4.4 矩阵-向量乘法
设
A
=
(
a
i
j
)
A = (a_{ij})
A=(aij)是一个
m
∗
n
m*n
m∗n的矩阵,
x
=
(
x
0
,
x
1
,
⋯
,
x
n
−
1
)
T
x=( x_0 , x_1 , ⋯ ,x_{n-1})^T
x=(x0,x1,⋯,xn−1)T是一个n维行向量,
A
x
=
y
Ax=y
Ax=y是一个m维列向量。
即对于
y
=
(
y
0
,
y
1
,
⋅
⋅
⋅
,
y
m
−
1
)
y=(y_0,y_1,···,y_{m-1})
y=(y0,y1,⋅⋅⋅,ym−1)来说,
y
i
=
∑
k
=
0
n
−
1
a
i
k
x
k
y_i=\sum_{k=0}^{n-1}a_{ik}x_k
yi=∑k=0n−1aikxk。
矩阵乘法的串行程序的伪代码:
/* For each row of A */
for (i = 0; i < m; i++) {
y[i] = 0.0;
/* For each element of the row and each element of x */
for (j = 0; j < n; j++)
y[i] += A[i][j] * x[j];
}
并行实现矩阵向量乘法的代码:
第一行: q ∗ m t q*\frac{m}{t} q∗tm
最后一行: ( q + 1 ) ∗ m t (q+1)*\frac{m}{t} (q+1)∗tm
/*假设A 、X 、Y 、m 和n 都是全局共享变量*/
void* Pth_mat_vect(void* rank) {
long my_rank = (long) rank;
int i, j;
int local_m = m / thread_count;
int my_first_row = my_rank * local_m;
int my_last_row = (my_rank + 1) * local_m - 1;
for(i = my_first_row; i<= my_last_row; i++) {
y[i] = 0.0;
for(j = 0; j < n; j++)
y[i] = A[i][j] * x[j];
}
return NULL;
} /* Pth_mat_vect */
4.5 临界区
- 当多个线程尝试更新共享资源时,结果可能无法预测。
- 当执行的结果取决于两个或多个事件的先后顺序时,就存在
竞态条件(race condition)
。临界区(critical section)
就是一个代码块,用于更新一次只能由一个线程更新的共享资源。
通过公式估算 π \pi π的值:
π = ( 1 − 1 3 + 1 5 − 1 7 + ⋅ ⋅ ⋅ + 1 2 n + 1 + ⋅ ⋅ ⋅ ) \pi=(1-\frac{1}{3}+\frac{1}{5}-\frac{1}{7}+···+\frac{1}{2n+1}+···) π=(1−31+51−71+⋅⋅⋅+2n+11+⋅⋅⋅)
串行伪代码:
double factor = 1.0;
double sum = 0.0;
for (i = 0; i < n; i++, factor = −factor) {
sum += factor / (2 * i + 1);
}
pi = 4.0 * sum;
尝试用并行化矩阵-向量乘法的方法来并行化这个程序:将for循环分块后交给各个线程处理,并将sum 设为全局变量。
计算 π \pi π的线程函数如下:
void* Thread_sum(void* rank) {
long my_rank = (long) rank;
double factor;
long long i;
long long my_n = n/thread_count;
long long my_first_i = my_n*my_rank;
long long my_last_i = my_first_i + my_n;
if (my_first_i % 2 == 0) /* my_first_i is even */
factor = 1.0;
else /* my first i is odd */
factor = −1.0;
for (i = my_first_i; i < my_last_i; i++, factor = −factor) {
sum += factor/(2*i+1);
}
return NULL;
} /* Thread_sum */
竞争条件(race condition):当多个线程都要访问共享变量或共享文件这样的共享资源时,如果至少其中一个访问是更新操作,那么这些访问就可能会导致某种错误。
临界区:一个更新共享资源的代码段,一次允许只一个线程执行该段代码。
4.6 忙等待
为了避免发生混乱,可以使用flag变量来维护临界区。
y = Compute(my_rank);
While(flag != my_rank);
x = x + y;
flag++;
while 循环语句就是忙等待的一个例子。在忙等待
中,线程不停地测试某个条件,但实际上,直到某个条件满足之前(在我们的例子中,是flag!=my_rank
条件为false
) ,这些测试都是徒劳的。
忙等待
这种方法有效的前提是,“严格地按照书写顺序来执行代码”。如果有编译器优化,那么编译器进行的某些代码优化的工作会影响到忙等待的正确执行。
循环后用临界区求全局和的函数:
void* Thread_sum(void* rank) {
long my_rank = (long) rank;
double factor,my_sum = 0.0;
long long i;
long long my_n = n/thread_count;
long long my_first_i = my_n*my_rank;
long long my_last_i = my_first_i + my_n;
if (my_first_i % 2 == 0) /* my_first_i is even */
factor = 1.0;
else /* my first i is odd */
factor = −1.0;
for (i = my_first_i; i < my_last_i; i++, factor = −factor) {
my_sum += factor/(2*i+1);
}
while (flag != my_rank);
sum += my_sum;
flag = (flag+1)%thread_count;
return NULL;
} /* Thread_sum */
4.7 互斥量(mutex)
互斥量
是互斥锁的简称,它是一个特殊类型的变量,通过某些特殊类型的函数,互斥量可以用来限制每次只有一个线程能进入临界区。- 互斥量保证了一个线程独享临界区,其他线程在有线程已经进入该临界区的情况下,不能同时进入。
- Pthreads 标准为互斥量提供了一个特殊类型:
pthread_mutex_t
初始化:
int pthread_mutex_init(
pthread_mutex_t* mutex_p /* out */,
const pthread_mutexattr_t* attr_p /* in */
)
参数解释:不使用第二个参数,给这个参数赋值NULL 即可
销毁:
int pthread_mutex_destroy(
pthread_mutex_t* mutex_p /* in/out */
)
要获得临界区的访问权:
/*调用pthread_mutex_lock 会使线程等待,直到没有其他线程进人临界区*/
int pthread_mutex_lock(
pthread_mutex_t* mutex_p /* in/out */
)
当线程退出临界区:
/*调用pthread_mutex_unlock 则通知系统该线程已经完成了临界区中代码的执行*/
int pthread_mutex_unlock(
pthread_mutex_t* mutex_p /* in/out */
)
使用互斥量
保护临界区,估算
π
\pi
π的值这一问题中,核心代码为:
pthread_mutex_lock(&mutex);
sum += my_sum;
pthread_mutex_unlock(&mutex);
- 当程序运行的线程少于内核时,总体运行时的差别并不大。
- 增加线程数量使之超过内核数量,使用互斥的版本的性能几乎保持不变,而忙等版本的性能会下降。
- 使用忙等时,如果线程多于内核,性能会下降。
- 加 速 比 = T 串 行 T 并 行 ≈ t h r e a d _ c o u n t 加速比=\frac{T_{串行}}{T_{并行}}\approx thread\_count 加速比=T并行T串行≈thread_count,当加速比等于线程数目时,就获得“理想”的性能或线性加速比。
4.8 生产者-消费者同步和信号量(semaphore)
信号量
可以认为是一种特殊类型的unsigned int
无符号整型变量- 大多数情况下,只给它们赋值0和1, 这种只有0和1值的信号量称为
二元信号量
。 - 注意,
semaphore
并不来自Pthreads
,要使用它需要头文件#include <semaphore.h>
。
int sem_init(
sem_t* semaphore_p, /* out */
int shared, /* in */
unsigned initial_val /* in */
) //初始化信号量
int sem_destroy(sem_t* semaphore_p /* in/out */);//该函数销毁信号量。
int sem_post(sem_t* semaphore_p /* in/out */); //该函数释放一个信号量,信号量的值加1。
int sem_wait(sem_t* semaphore_p /* in/out */); //该函数申请一个信号量,当前无可用信号量则等待,有可用信号量时占用一个信号量,对信号量的值减1。
参数解释:我们不使用 sem_init 函数的第二个参数,对这个参数只需传入常数0
即可
使用信号量让线程发送消息
/* semaphores are initialized to 0 (locked) */
void* Send_msg(void* rank) {
long my_rank= (long) rank;
long dest= (my_rank+ 1) % thread_count;
char* my_msg= malloc(MSG_MAX*sizeof(char));
sprintf(my_msg, "Hello to %ldfrom %ld", dest, my_rank);
messages[dest] = my_msg;
sem_post(&semaphores[dest]);
/*unlock the semaphore of dest*/
/*wait for our semaphore to be unlocked*/
sem_wait(&semaphores[my_rank]);
printf("Thread %ld> %s\n", my_rank, messages[my_rank]);
return NULL;
}
生产者-消费者同步模型:一个线程需要等待另一个线程执行某种操作的同步方式
4.9 路障(barrier)和条件变量(conditional variable)
4.9.1 路障
- 同步线程以确保它们都在程序中的同一点,这个称为
路障(barrier)
。 - 只有所有线程都抵达此路障,线程才能继续运行下去,否则会阻塞在路障处。
- 路障有很多应用
- 计时多线程程序 。希望所有线程能够在同一时间点开始计时,在最后一个线程完成时再报告时间。
/* Shared */
double elapsed_time;
···
/* Private */
double my_start,my_finish,my_elapsed;
···
Synchronize threads;
Store current time in my_start;
/* Execute timed code */
···
Store current time in my_finish;
my_elapsed = my_finish - my_start;
elapsed = Maximum of my_elapsed values;
- 调试程序。可以让每个线程都打印消息,来表明它运行到程序的哪个点。
point in program we want to reach;
barrier;
if (my_rank==0){
printf("All threads reached this point.\n");
fflush(stdout);
}
4.9.2 忙等待+互斥量
使用忙等待和互斥量实现路障的思路如下:
维护一个由互斥量保护的共享计数器。当计数器指示每个线程已经进入临界区时,线程可以离开一个忙等循环。实现的伪代码如下:
/* Shared and initialized by the main thread */
int counter; /* Initialize to 0 */
int thread_count;
pthread_mutex_t barrier_mutex;
void Thread_work(...) {
/* Barrier */;
pthread_mutex_lock(&barrier_mutex);
counter++;
pthread_mutex_unlock(&barrier_mutex);
while(counter < thread_count);
}
使用这种方法实现路障,会遇到忙等待面临的问题,即在很大程度上牺牲了性能。
同时,还有一个问题在于共享变量counter
。在上述代码中,如果想实现第二个barrier并尝试重用计数器counter:
int counter; /* Initialize to 0 */
int thread_count;
pthread_mutex_t barrier_mutex1,barrier_mutex2;
void Thread_work(...) {
/* Barrier1 */;
pthread_mutex_lock(&barrier_mutex1);
counter++;
pthread_mutex_unlock(&barrier_mutex1);
while(counter < thread_count);
/* Barrier2 */;
pthread_mutex_lock(&barrier_mutex2);
counter++;
pthread_mutex_unlock(&barrier_mutex2);
while(counter < thread_count);
}
当第一个barrier完成时,counter
的值将变成thread_count
。若不重置计数器,那么在第二个barrier中,while条件(counter < thread_count
)将为false
,这样barrier就不会导致线程阻塞。此外,安全地将计数器重置为零的几乎不可能。因此,每个barrier都要有一个自己的计数器变量(如果有需要的话)。
如果想要使用这种实现方式的路障,则有多少个路障就必须要有多少个不同的共享counter变量来进行计数。
4.9.3 信号量
/* Shared variables */
int counter; /* Initialize to 0 */
sem_t count_sem; /* Initialize to 1 */
sem_t barrier_sem; /* Initialize to 0 */
void* Thread_work(...) {
/* Barrier */
sem_wait(&count_sem);
if(counter == thread_count - 1) { // 此时进入的是最后一个线程
counter = 0;
sem_post(&count_sem);
for(j = 0; j < thread_count - 1; j++) {
sem_post(&barrier_sem);
}
} else {
counter++;
sem_post(&count_sem); // count_sem++;
sem_wait(&barrier_sem); // barrier_sem--; 由于semaphore是无符号整型,barrier_sem一旦小于零,线程将会阻塞。
}
}
使用一个计数器counter
来判断有多少线程进入路障。
我们采用两个信号量: count_sem
, 用于保护计数器; barrier _sem
, 用于阻塞已经进入路障的线程。
这种情况下,计数器counter
可以重用,因为在释放barrier中的任何线程之前,都小心地重置了它。另外,因为在任何线程离开barrier之前,count_sem
都被重置为1,所以它可以重用。
4.9.4 条件变量
条件变量
是一个数据对象,允许线程在某个特定条件或事件发生前都处于挂起状态。- 当事件或条件发生时,另一个线程可以通过信号来唤醒
挂起
的线程。 - 一个条件变量总是与一个互斥量相关联。
- 使用伪代码:
lock mutex;
if condition has occurred
signal thread(s);
else {
unlock the mutex and block;
/* when thread is unblocked, mutex is relocked */
}
unlock mutex;
Pthreads中的条件变量的类型为pthread_cond_t
。
/*解锁一个阻塞的线程*/
int pthread_cond_signal(
pthread_cond_t* cond_var_p /* in/out */
)
/*解锁所有被阻塞的线程*/
int pthread_cond_broadcast(
pthread_cond_t* cond_var_p /* in/out */
)
/*通过互斥锁mutex_p 来阻塞线程,直到其他线程调用pthread_cond_signal 或者pthread_cond_broadcast 来解锁它*/
int pthread_cond_wait(
pthread_cond_t* cond_var_p, /* in/out */
pthread_mutex_t* mutex_p /* in/out */
)
//相当于按顺序执行了以下函数:
pthread_mutex_unlock(&mutex_p);
wait_on_signal(&cond_var_p);
pthread_mutex_lock(&mutex_p)
/*不使用 pthread_cond_init 的第二个参数(调用函数时传递NULL 作为参数值)*/
int pthread_cond_init(
pthread_cond_t* cond_p, /* out */
const pthread_condattr_t* cond_attr_p /* in */
)
int pthread_cond_destroy(pthread_cond_t* cond_p /* in/out*/);
下面的代码是用条件变量实现路障:
/* Shared */
int counter = 0;
pthread_mutex_t mutex;
pthread_cond_t cond_var;
. . .
void* Thread work(. . .) {
. . .
/* Barrier */
pthread_mutex_lock(&mutex);
counter++;
if (counter == thread_count) {
counter = 0;
pthread_cond_broadcast(&cond_var);
} else {
while (pthread_cond_wait(&cond_var, &mutex) != 0);
}
pthread_mutex_unlock(&mutex);
. . .
}
函数pthread_cond_wait一般放置于while循环内,如果线程不是被 pthread_cond_broadcast 或 pthread_cond_signal 函数而是被其它事件解除阻塞,那么能检查到pthread_cond_wait函数的返回值不为0,被解除阻塞的线程还会再次执行该函数。
4.10 读写锁(read-write lock)
- 提供两个锁函数以外,读写锁基本上与互斥量差不多。
- 第一个为
读操作
对读写锁进行加锁,第二个为写操作
对读写锁进行加锁。 多
个线程能通过调用读锁
函数而同时获得锁,但只有一
个线程能通过写锁
函数获得锁。- 如果任何线程拥有了
写锁
,则任何想获取读或写锁的线程将阻塞在它们对应的锁函数上。
使用Pthreads 的读写锁,能用下列的代码保护链表函数(忽略函数返回值):
pthread_rwlock_rdlock(&rwlock);
Member(value);
pthread_rwlock_unlock(&rwlock);
. . .
pthread_rwlock_wrlock(&rwlock);
Insert(value);
pthread_rwlock_unlock(&rwlock);
. . .
pthread_rwlock_wrlock(&rwlock);
Delete(value);
pthread_rwlock_unlock(&rwlock);
Pthreads 函数的语法是:
/*第一个函数为读加锁*/
int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock_p); /* in */
/*第二个函数为写加锁*/
int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock_p); /* in/out */
/*第三个函数为解锁*/
int pthread_rwlock_unlock(pthread_rwlock_t* rwlock_p); /* in/out */
/*不使用pthread_cond_init 的第二个参数(调用函数时传递NULL 作为参数值)*/
/*初始化*/
int pthread_rwlock_init(
pthread_rwlock_t* rwlock_p, /*out*/
const pthread_rwlockattr_t* attr_p /* in */
)
/*释放一个读写锁*/
int pthread_rwlock_destroy(pthread_rwlock_t* rwlock_p /* in/out */);
读写锁实现比单互斥量和每个节点一个互斥量的实现更好。
4.11 缓存、线程安全性
- 时间/空间局部性:如果一个处理器在时间t访问内存位置x,那么很可能它在一个接近t的时间访问接近x的内存位置。
如果一个处理器需要访问主存位置x, 那么就不只是将x的内容传人(出)主存,而是将一块包含x 的内存块传入(出)主存。我们将这样一块内存称为缓存行或者缓存块。 - 伪共享:缓存一致性硬件强迫多个线程看起来好像是共享同一个内存单元。
- 线程安全性:如果一个代码块能够被多个线程同时执行而不引起问题,那么它是线程安全的。
注:仅用于自己复习,不作他用。