第4章:用Pthreads 进行共享内存编程

本文详细介绍了如何使用POSIX线程库(Pthreads)进行共享内存编程,涵盖了线程创建、运行、停止,以及矩阵-向量乘法、临界区、忙等待、互斥量、信号量、路障、条件变量和读写锁等同步机制。通过实例展示了如何解决线程间的同步问题,强调了线程安全和性能优化的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

【并行程序设计导论】第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 mn的矩阵, x = ( x 0 , x 1 , ⋯ , x n − 1 ) T x=( x_0 , x_1 , ⋯ ,x_{n-1})^T x=(x0,x1,,xn1)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,,ym1)来说, y i = ∑ k = 0 n − 1 a i k x k y_i=\sum_{k=0}^{n-1}a_{ik}x_k yi=k=0n1aikxk

矩阵乘法的串行程序的伪代码:

/* 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} qtm

最后一行: ( 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}+···) π=(131+5171++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 =TTthread_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)
  • 只有所有线程都抵达此路障,线程才能继续运行下去,否则会阻塞在路障处。
  • 路障有很多应用
  1. 计时多线程程序 。希望所有线程能够在同一时间点开始计时,在最后一个线程完成时再报告时间。
/* 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; 
  1. 调试程序。可以让每个线程都打印消息,来表明它运行到程序的哪个点。
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 的内存块传入(出)主存。我们将这样一块内存称为缓存行或者缓存块
  • 伪共享:缓存一致性硬件强迫多个线程看起来好像是共享同一个内存单元。
  • 线程安全性:如果一个代码块能够被多个线程同时执行而不引起问题,那么它是线程安全的。

注:仅用于自己复习,不作他用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值