使用pthread进行编程
使用pthread进行并行编程
进程与线程
进程是一个运行程序的实例;线程像一个轻量级的进程;在一个共享内存系统中,一个进程可以有多个线程
POSIX® Threads:
即 Pthreads,是一个 Unix 系统标准;一个可以用于 C 语言的库;是多线程编程的一个 API 接口。
第一个 pthreads "hello, world"程序:
#include <stdio.h>
#include <stdlib.h>
//pthread 线程库的头文件
#include <pthread.h>
//定义线程数量
int thread_count=4;
void* Hello(void* rank);//负载函数
int main(int argc, char* argv[]) {
pthread_t* thread_handles;
thread_handles=(pthread_t*)malloc(thread_count*sizeof(pthread_t));
for (int i=0; i< thread_count; i++){
pthread_create(&thread_handles[i], NULL, Hello, (void*)i);
}
printf("Hello from the main thread\n");
for (int i=0; i < thread_count; i++)
pthread_join(thread_handles[i], NULL);
free(thread_handles);
return 0;
}
void* Hello(void* rank){
long my_rank = (long) rank;
printf("Hello from thread %ld of %d\n", my_rank, thread_count);
return NULL;
}
启动线程
Pthread 是由程序来启动线程的,这样就需要在程序中添加相应的代码来显式启动线程,并构造能够储存信息的数据结构。
thread_handles=(pthread_t*)malloc(thread_count*sizeof(pthread_t));
//为每个线程的 pthread_t 分配内存,pthread_t 数据结构用来存储线程的专有信息,它由 pthread.h 声明
pthread_t 对象是一个不透明对象。对象存储的数据都是由系统决定的,用户级代码无法直接访问;Pthreads 标准保证 pthread_t 能够存储足够信息来标识唯一线程。
int pthread_create (pthread_t* thread_p ,
const pthread_attr_t* attr_p ,
void* (*start_routine ) ( void ) ,
void* arg_p ) ;
//第一个参数是一个指针,指向对应的 pthread_t 对象。
//第二个参数一般用 NULL 就行
//第三个参数表示该线程将要运行的函数。
//最后一个参数也是一个指针,指向传给函数 start_routine 的参数列表。
- pthread_t 对象不是由 pthread_create 函数分配的,必须在调用 pthread_create 函数前就为 pthread_create 函数前就为 pthread_t 对象分配内存空间。
- pthread_create 创建的函数:
void* thread_function ( void* args_p ) ;//原型
void* 可以转为任意 C 类型;args_p 可以指向任何参数;函数返回值可以是任何内容。
需要注意的是:我们为每一个线程分配不同的编号只是为了方便使用。事实上,pthread_create 创建线程并没有要求必须传递线程号,也没有要求必须要分配线程号给一个线程。
运行线程
运行 main 函数的线程一般称为主线程。所以在线程启动后有一句这样的打印:
printf("Hello from the main thread\n");
同时,调用 pthread_create 所生成的线程也在运行。所以这一句的打印出现在中间。
在 pthread 中,程序员不直接控制线程在哪个核上运行。在 pthread_create 函数中,没有参数用于指定在哪个核上运行线程。线程的调度是由操作系统来做的。
停止线程
依次为每个线程调用一次 pthread_join 函数。调用一次 pthread_join 将等待 pthread_t 对象所关联的那个线程结束。
int pthread_join(pthread_t thread, void**);
第二个参数可以接受任意由 pthread_t 对象所关联的线程的那个线程产生的返回值。
矩阵向量乘法
串行程序伪代码
for (i = 0; i < m; i++){
y[i] = 0.0;
for (j = 0; j < n; j++)
y[i] += A[i][j]*x[j];
}
通过把工作分配给各个线程将程序并行化。一种分配方法是将线程外层的循环分块,每个线程计算 y 的一部分。
//被分配给 y[i]的线程将执行代码
y[i] = 0.0;
for (j = 0; j < n; j++)
y[i] += A[i][j]∗ x[j];
并行代码
假设 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;
}
临界区
估算 pi 值的例子
串行运行代码
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;
计算 pi 的线程函数
将 for 循环方块后交给各个线程处理,并将 sum 设为全局变量
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)
factor = 1.0;
else
factor = −1.0;
for (i = my first i; i < my last i; i++, factor = −factor){
sum += factor/(2*i+1);
}
return NULL;
}
当多个线程都要访问共享变量或者共享文件这样的共享资源时,如果至少其中一个访问是更新操作,那么这些访问就可能会导致某种错误,我们称为竞争条件。因此,更新共享资源的代码段一次只能允许一个线程执行,称为临界区。
忙等待
线程循环测试条件, 直到满足条件 (注意编译器可能会进行优化,使忙等待失效,最简单的措施就是关闭编译器优化选项)
y= Compute(my_rank);
while (flag != my_rank);
x = x + y;
flag++;
忙等待可能造成 cpu 资源的浪费,关闭编译器优化选项同样也可能降低性能。
简单的对 flag 值进行加 1 存在隐患,对 flag++的语句进行改造后的程序:
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)
factor = 1.0;
else
factor = −1.0;
for (i = my_first_i; i < my_last i; i++, factor = −factor){
while (flag != my rank);
sum += factor/(2*i+1);//临界区
flag = (flag+1) % thread count; //在线程 t-1 离开临界区时,应该将 flag 值重置为 0
}
return NULL;
}
循环后用临界区求全局和的函数:
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)
factor = 1.0;
else
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; //在线程 t-1 离开临界区时,应该将 flag 值重置为 0
return NULL;
}
互斥量
线程使用忙等待会持续消耗 CPU 计算资源;
互斥量是一种特殊的变量,使得同一时间只有一个线程可以访问临界区。
当一个线程在使用临界区时,保证其它线程无法访问;
Pthreads 的互斥量: pthread_mutex_t.
使用 pthread_mutex_t 前,必须由系统
int pthread_mutex_init( pthread_mutex_t∗ mutex_p, const pthread_mutexattr_t∗ attr_p);
当一个线程使用完互斥量后,应该调用:
int pthread_mutex_destroy(pthread_mutex_t* mutex_p);
要获得临界区的访问权,线程需要调用:
int pthread_mutex_lock(pthread_mutex_t∗ mutex_p);
当线程退出临界区后,它应该调用:
int pthread_mutex_unlock(pthread_mutex_t∗ mutex_p);
pthread_mutex_lock 使线程等待,直到没有其他线程进入临界区。;调用~unlock 则通知系统该线程已经完成了临界区中代码的执行。
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;
double my_sum = 0.0;
if (my_first_i % 2 == 0)
factor = 1.0;
else
factor = −1.0;
for (i = my first i; i < my last i; i++, factor=−factor{
my_sum += factor/(2*i+1);
pthread_mutex_lock(&mutex);
sum += my sum;
pthread mutex unlock(&mutex);
}
return NULL;
}
比较忙等待和互斥量的程序性能,当线程个数少于核的个数时,两者的执行时间并没有很大差别。当线程数超过核的个数,互斥量程序的性能依旧维持不变,但是忙等待的性能就会下降。
生产者-消费者同步和信号量
遇到的问题
忙等待方法可以保证线程对临界区访问的顺序,但效率不高;互斥量效率更高,但无法保证顺序;
信号量方法
信号量可以认为是一种特殊类型的 unsigned int 无符号整型变量,可以赋值为 0,1,2,3 等,一般只赋 0(对应上锁的互斥量)/1(未上锁的互斥量)。要把一个二元互斥量用作互斥量时候=,需要把信号量的值初始化为 1,即开锁状态。在要保护的临界区前调用函数 sem_wait,线程执行到 sem_wait 函数时,如果信号量为 0,线程就会被阻塞,否则减 1 后进去临界区。执行完临界区的操作后,再调用 sem_post 对信号量的值加 1,使得在 sem_wait 中阻塞的其他线程能够继续运行。
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 %ld from %ld", dest, my_rank); messages[dest] = my_msg;
sem_post(&semaphores[dest]); sem_wait(&semaphores[my_rank]);
printf("Thread %ld > %s n", my_rank, messages[my_rank]); return NULL;
}
不同信号量的语法为:
int sem_init(sem_t∗ semaphore_p, int shared, unsigned initial_val );
int sem_destroy(sem_t∗ semaphore_p);
int sem_post(sem_t∗ semaphore_p);
int sem_wait(sem_t∗ semaphore_p);
注意:信号量不是 Pthreads 线程库的一部分,所以在使用信号量的程序开头加头文件
#include <semaphore.h>
以上这种一个线程需要等待另一个线程执行某种操作的同步方式,有时候称为生产者-消费者模型。
路障和条件变量
作用
使线程之间同步,并保证它们运行到了同一个位置。
没有线程可以越过设置的路障,直到所有线程都抵达这里。
使用路障来计时
使用路障来调试
忙等待和互斥量
使用互斥量和忙等待来实现路障的方法;
使用一个通过互斥量保护的计数器;
当计数器表明,所有线程都进入过临界区, 线程就可以离开了。
实现
问题:依旧使用了忙等待,浪费 cpu 周期。
使用信号量实现路障
count_sem 由于保护计数器,barrier_sem 用于阻塞已经进入路障的线程。
条件变量
一个条件变量允许停止一个线程,直到某个事件发生;
当条件被满足时,另一个线程可以激活这个线程;
条件变量总是和互斥量绑在一起。
伪代码
实现
读写锁
控制对一大片共享数据的访问
看一个例子:
假如有一个共享的排序链表, 对链表的操作有 Member, Insert, 和 Delete.
、
member 函数
支持多线程的链表
如何在 Pthreads 中使用链表?
为了使用这个链表, 我们可以将 head_p 定义为一个全局变量,这样简化了链表的参数传递
两个线程同时访问
解决方法 1:对整个链表上锁
上述操作可以通过一个互斥量来控制访问。
问题
对链表的访问是串行的;
如果是 Member 操作,会浪费大量并行性;
如果是 Insert 和 Delete 操作, 则比较适合
解决方法 2:对局部上锁
这是一种细粒度的方法:
问题
这使得 Member 变得很复杂;
性能会很慢, 因为每次访问一个节点的时候,都需要上锁和解锁;
互斥量也会增加系统的存储负担。
解决方法 3:Pthread 读写锁
上述两个方法都有缺陷:
第一个方案只允许同一时间一个线程访问;第二个方案只允许同一时间只有一个线程访问一个节点。
读写锁有点像互斥量,但提供两个方法;:第 1 个用来对读上锁,而第 2 个用来对写上锁;
很多线程都可以获得读锁,但只有一个线程可以获得写锁。
如果有线程获得了读锁,那么其他线程无法获得写锁。
方法
线程安全性
一个代码块能够同时被多个线程调用而不产生问题,那么它是线程安全的。
eg:假设我们想对一个文件进行分词;文本由空格和字符组成。
简单方法:将文本分为很多行,然后交给不同的线程处理。通过信号量来控制对行的访问;当一个线程获得了一行后, 可以使用 strtok 来进行分词。
在第一次调用时,strtok 会将字符指针缓存, 在接下来的调用中返回分隔出的词。
void Tokenize(void rank){
long my_rank = (long) rank;
int count; int next = (my_rank + 1) % thread_count; char fg_rv; char my_line[MAX];
char my_string;
sem wait(&sems[my_rank]);//强制线程按顺序输入行
fg_rv = fgets(my_line, MAX, stdin); sem_post(&sems[next]);
while (fg_rv != NULL){
printf("Thread %ld > my_line = %s", my_rank, my_line);
count = 0;
my_string = strtok(my_line, "\t\n");
while ( my_string != NULL ){
count++;
printf("Thread %ld > string %d = %s n", my_rank, count,my_string);
my_string = strtok(NULL, "\t\n");
}
sem_wait(&sems[my_rank]);
fg_rv = fgets(my_line, MAX, stdin);//读一行输入 sem_post(&sems[next]);
}
return NULL;
}
正确输入和输出:
单线程没有问题,多线程出错:
strtok 对数据进行了缓存;下次调用,会对缓存数据进行解析;不幸的是,缓存区是共享的,而不是私有的。因此,线程 0 调用 strtok 对输入的第三行进行缓存,覆盖了原来线程 1 调用 strtok 输入输入的第二行的缓存。因此,strtok 是线程不安全的。
在某些情况下, C 标准会提供要给线程安全的方案: