系列文章目录
- pthreads并行编程(上)
- pthreads并行编程(中)
- pthreads并行编程(下)
- 使用OpenMP进行共享内存编程
文章目录
- 系列文章目录
- 前言
- 一、Pthreads
- Pthreads 库的主要特性包括:
- 二、`Hello World`程序
- 2.1 准备工作
- 2.2 启动线程
- 2.3 运行线程
- 2.3.1 线程函数
- 2.4 停止线程
- 三、矩阵向量乘法
- 3.1 定义矩阵和向量结构及线程参数结构
- 3.2 线程函数
- 3.3 主函数测试
- 3.4结果展示
- 总结
- 参考文献
前言
共享内存系统:
共享内存系统中的任意处理器核都能访问所有的内存区域。因此,协调各个处理器核工作的一个方法,就是把某个内存设为“共享”
进程和线程:
- 进程,不共享内存,是操作系统资源分配的基本单元。比如QQ程序运行占一个进程。
- 线程,共享内存,是程序执行的基本单位。比如QQ里面的视频聊天占QQ这个进程中的一个线程。
一个程序至少有一个进程,一个进程至少有一个线程,一个线程只属于一个进程。
单个进程可能有多个线程
一、Pthreads
Pthreads(POSIX线程)库是一个在多种操作系统上实现的标准化并发编程接口。它提供了一套用于创建和控制线程的函数,允许开发者在支持 POSIX 标准的类Unix操作系统(如 Linux、macOS 和 UNIX 系统)上进行多线程编程。Pthreads 库是基于 C 语言的,因此它非常适用于需要低层操作和高性能的应用程序。
Pthreads 库的主要特性包括:
-
线程的创建和终止:
- 创建:
pthread_create
函数用来创建一个新线程,同时可以指定线程的属性和执行的函数。 - 终止:线程可以通过
pthread_exit
函数自我终止,或被其他线程通过pthread_cancel
函数取消。
- 创建:
-
线程同步:
- 互斥锁(Mutexes):用于控制对共享资源的访问。互斥锁确保同一时间只有一个线程可以访问某个资源。
- 条件变量:允许线程在某些条件下挂起执行或者等待其他线程的通知。
- 读写锁:允许更高效的数据访问,多个线程可以同时读取一个资源,但写操作会独占资源。
-
线程属性管理:
- 线程可以具有各种属性,如堆栈大小、调度策略等,这些可以通过 Pthreads 提供的函数进行设置。
-
线程局部存储:
- 线程局部存储(Thread Local Storage,TLS)允许每个线程有自己的变量实例,线程之间不共享这些变量。
-
信号处理:
- 线程可以响应和处理信号,但管理信号在多线程环境中可以变得复杂。
Pthreads 提供了强大的工具集,用于构建复杂的并行应用程序,但也要求开发者仔细管理线程之间的同步,以避免竞态条件、死锁等多线程常见的问题。
二、Hello World
程序
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
int thread_count; //表示线程个数,被线程所共享
void* Hello(void* rank);
int main(int argc, char* argv[]){
long thread;
pthread_t* thread_handles;
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;
}
void *Hello(void* rank){
long my_rank = (long)rank;
printf("Hello from thread %ld of %d\n", my_rank, thread_count);
return NULL;
}
编译
gcc -g -Wall -o pth_hello pth_hello.c -lpthread
运行
./pth_hello <number of threads>
运行结果
可以发现,每次运行结果的顺序都是不固定的
2.1 准备工作
- 头文件:
pthread.h
- 全局变量:thread_count
- 获取线程数
thread_count = strtol(argv[1], NULL, 10);
首先先来解释strtol
函数的基本语法:
//来自<stdlib.h>
long int strtol(const char *str, char **endptr, int base);
- str:要转换的字符串。
- endptr:指向转换中断位置的指针的指针。如果不需要这个信息,可以传递 NULL。
- base:转换所使用的数值基数,如10代表十进制。
这段代码 thread_count = strtol(argv[1], NULL, 10);
是在 C 或 C++ 程序中用于从命令行参数中获取并转换整数值的一种方法。这里的函数 strtol 用于将字符串转换为长整型数(long 类型)。逐部分解析这个函数调用:
argv[1]:
- argv 是一个字符串数组(char* argv[]),通常在 C 或 C++ 程序的主函数定义中作为参数传入,用来接收命令行参数。
- argv[0] 通常是程序的名称,argv[1] 是传递给程序的第一个参数。
NULL:
- 这是 strtol 函数的第二个参数,用来存储指向转换停止处的字符的指针。在这个用法中,我们不关心转换在哪里停止,因此传入 NULL。
10:
- 这是 strtol 函数的第三个参数,指定了数值的基数。在这个例子中,10 表示数值是按十进制进行解析的。
综合来看,这行代码的作用是将命令行提供的第一个参数(假定为字符串形式的整数)转换成一个 long 类型的数值,并将这个数值赋给变量 thread_count。这种做法常见于需要用户指定线程数量或其他数值输入的并发程序中。
这段代码就表示从你的命令行参数中的
./pth_hello <number of threads>
读取这个<number of threads>
线程数
2.2 启动线程
-
显式地启动线程并构造能够存储线程信息的数据结构。这里用的数据结构是顺序表
pthread_t *thread_handles
。 -
为每个线程的pthread_t对象分配内存,pthread_t数据结构用来存储线程的专有信息,由pthread.h声明
thread_handles = malloc(thread_count * sizeof(pthread_t));
-
创建线程
pthread_create(&thread_handles[thread], NULL, Hello, (void*)thread);
该函数原型是int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);
函数
pthread_create
是 POSIX 线程库中用于创建新线程的函数。这个函数的原型如下:这个函数有四个参数:
-
thread(第一个参数):
- 类型为
pthread_t*
,这是一个指向线程标识符的指针。在这个例子中,&thread_handles[thread]
是一个指向thread_handles
数组中相应位置的指针,用来存储新创建的线程的标识符。这里,pthread_create()不能直接分配pthread_t对象,必须在此之前为其分配相应的内存空间
- 类型为
-
attr(第二个参数):
- 类型为
const pthread_attr_t *
,用于设置线程属性。如果传递NULL
,线程将使用默认属性。在这个例子中,传递了NULL
,表示使用默认的线程属性。
- 类型为
-
start_routine(第三个参数):
- 类型为
void *(*)(void *)
,这是一个函数指针,指向线程将要执行的函数。在这个例子中,函数是Hello
,它接受一个void*
类型的参数并返回一个void*
类型的结果。
- 类型为
-
arg(第四个参数):
- 类型为
void *
,这是传递给start_routine
的参数。在这个例子中,传递的是thread
变量的地址,它被强制转换为void*
类型。这样做是因为pthread_create
函数要求参数必须是void*
类型,而thread
是long
类型的变量,表示当前线程的序号。
- 类型为
当
pthread_create
被调用时,它会创建一个新线程,并使该线程开始执行指定的start_routine
函数。在这个例子中,每个线程都执行Hello
函数,Hello
函数打印出线程的序号和总线程数。返回值:
pthread_create
函数返回一个整数,表示操作的成功或失败状态。如果成功,返回 0;如果失败,返回错误代码。在实际使用中,通常会检查这个返回值以确保线程创建成功。
-
2.3 运行线程
注意: 有之前的运行结果可以知道: 循环创建线程:通过pthread_create函数创建thread_count个线程,每个线程执行Hello函数,并传递其序号作为参数。和printf(“Hello from the main thread\n”);:主线程打印一条消息。这两个命令是几乎同时运行的(不存在上个语句先出现所以先运行的情况)
这是因为: 在多线程编程中,
pthread_create
函数的调用启动一个新线程,但并不会暂停或阻塞调用它的线程(即主线程)。当你在循环中调用pthread_create
时,每次调用都快速地启动一个新线程,并立即返回,允许下一次迭代或程序中的其他代码继续执行。这就意味着主线程会在几乎与这些新线程同时的时间内继续执行其余的代码。
因此,当你在循环后立即使用printf("Hello from the main thread\n");
时,这条消息的打印并不需要等待之前启动的所有线程完成。主线程会继续执行到这条printf
语句并输出消息,而此时新创建的线程可能已经开始执行,也可能还在启动过程中。这就是为什么这两个操作看起来是“同时”运行的:
1. 线程并发性:pthread_create
启动的线程可能会在任何时间点开始执行,这取决于操作系统的线程调度。因此,这些线程中的任何一个都可能在主线程打印消息之前、同时或之后开始执行其Hello
函数。
2. 主线程的继续执行:主线程在调用pthread_create
后不会停止或等待(除非显式调用如pthread_join
等同步函数),因此它会立即执行后续的printf
语句。
这种“同时”运行的情况是多线程程序设计的常见特征,反映了并发执行的本质,其中多个线程可以独立或几乎同时进行,而不是顺序执行。
2.3.1 线程函数
- 由
pthread_create
生成并运行的函数 - 原型
void *thread_function(void *arg_p)
这个例子中的线程函数是void* Hello(void *rank)
2.4 停止线程
由pthread_join()
函数等待所有线程完成并最终结束。pthread_join(thread_handles[thread], NULL);
该函数原型是:
int pthread_join(pthread_t thread, void **retval);
这个函数有两个参数:
- thread(第一个参数):
类型为 pthread_t,代表需要等待的线线程的标识符。这个标识符是在之前调用 pthread_create 时获得的。 - retval(第二个参数):
类型为 void**,用于存储线程的返回值。如果不关心线程返回什么值,可以传递 NULL。在你的示例代码中,传递的是 NULL,表示主线程不需要获取从线程 Hello 返回的任何值。
作用:
当你在主线程中调用 pthread_join(thread_handles[thread], NULL); 时,这个调用会阻塞主线程,直到 thread_handles[thread] 指定的线程完成其执行。这个过程中,如果被等待的线程已经结束,pthread_join 会立即返回;如果线程尚未结束,pthread_join 会使主线程阻塞,直到那个线程结束。
为什么使用 pthread_join
- 资源回收:确保当线程结束时,相关资源被适当回收。这包括线程的内部状态和任何可能分配的内存。
- 同步:有时候,可能需要确保线程按照特定的顺序完成任务。pthread_join 允许开发者控制程序的执行流程,确保所有线程按预期完成,再继续执行依赖这些线程结果的代码。
示例中的使用
在代码示例中,循环中对每一个线程调用 pthread_join,这确保了所有线程都完成它们的 Hello 函数执行后,主线程才会继续向下执行并最终退出。这个过程是必要的,因为如果主线程在创建的线程还在运行时结束了,整个程序也会结束,这可能导致线程中的任务没有完成就被中断。通过使用 pthread_join,主线程会等待每个线程正确地完成它们的任务。
为什么取名join,因为最后都要合并到主线程上去
思考:为什么第二个参数是void**
而不是void*
是因为这个参数的目的是接收线程函数返回的值的地址。使用 void**
允许 pthread_join
函数把线程函数结束时返回的 void*
类型的值存储在用户提供的地址中。
在 C 语言中,如果想在一个函数内修改某个变量的值,并且希望这个修改在函数外也有效,通常需要传递该变量的地址(即使用指针)。如果传递了一个指向某个数据的指针(即 void*
),那么你可以在函数内部修改这个数据的内容。然而,如果想在函数内部修改指针本身的值(例如,使它指向一个不同的地址),需要传递一个指向该指针的指针(即 void**
)。
在多线程环境中,每个线程可能会有一个返回值,这个值是通过线程的启动函数的返回来传递的。这个返回值是一个 void*
类型,因为它被设计为足够通用,能够指向任何类型的数据。当 pthread_join
被调用时,它需要一个地方来存储这个返回值,所以需要提供一个指向 void*
的指针(即 void**
),pthread_join
将线程的返回值存储在这个指向 void*
的指针所指向的位置。
示例
假设线程函数返回一个指向整数的指针:
void* thread_function(void* arg) {
int* result = malloc(sizeof(int));
*result = 42; // 任意的返回值
return result;
}
这样使用 pthread_join
来接收这个返回值:
void* thread_result;
pthread_t thread;
pthread_create(&thread, NULL, thread_function, NULL);
pthread_join(thread, &thread_result);
int* result = (int*) thread_result; // 转换为正确的类型
printf("Thread returned %d\n", *result);
free(result); // 记得释放内存
在这个例子中,pthread_join(thread, &thread_result);
调用中,&thread_result
是 void**
类型,它提供了一个存储从 thread_function
返回的 void*
值的位置。这样,就可以在 pthread_join
后访问并处理这个线程返回的数据。
三、矩阵向量乘法
怎么来分配线程呢?最先想到的就是m行矩阵,由t个线程(假设m % t = 0
)那么每个线程计算(m/t)行。
3.1 定义矩阵和向量结构及线程参数结构
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
// 定义结构体用于传递数据给线程
typedef struct ThreadData{
int rank; // 线程的标识(ID或rank)
int start_row;
int end_row;
int num_cols;
double* matrix;
double* vector;
double* result;
} ThreadData;
/*
**在多线程编程中,尤其是在使用 POSIX 线程(pthreads)库时,ThreadData结构的作用是传递多个数据或参数给线程函数。
**由于 pthread 的线程函数只能接受一个 void* 类型的参数,如果需要传递多个参数到线程,就需要使用一个结构体来封装这些参数。
*/
3.2 线程函数
void* pth_mat_vect(void* arg){
ThreadData* td = (ThreadData*)arg;
printf("Thread %d is running. Processing rows from %d to %d.\n", td->rank, td->start_row, td->end_row - 1);
for (int i = td->start_row; i < td->end_row; i++){
td->result[i] = 0; //得先初始化
for (int j = 0; j < td->num_cols; j++){
td->result[i] += td->matrix[i * td->num_cols + j] * td->vector[j];
}
}
return NULL;
}
3.3 主函数测试
int main(int argx, char* argv[]){
int num_threads = strtol(argv[1], NULL, 10);
int num_rows = 10; // 示例:矩阵行数
int num_cols = 10; // 示例:矩阵列数和向量的元素数
double m[num_rows * num_cols];
double v[num_cols];
double result[num_rows];
// 初始化矩阵和向量
srand(time(0));
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
m[i * num_cols + j] = rand() % 10; // 示例初始化
}
}
for (int i = 0; i < num_cols; i++) {
v[i] = rand() % 20; // 示例初始化
}
// 创建线程数组和数据结构数组
pthread_t threads[num_threads];
ThreadData thread_data[num_threads];
//声明并初始化了一个数组,其每个元素都是 ThreadData 类型的结构体。
//这个结构体被设计为存储每个线程所需要的所有数据,以便线程可以独立地执行它的任务。
// 分配每个线程处理的行数
int rows_per_thread = num_rows / num_threads;
int extra_rows = num_rows % num_threads; // 行数除以线程数除不尽,剩下的(余数)
int start_row = 0;
for (int i = 0; i < num_threads; i++){
thread_data[i].rank = i;
thread_data[i].start_row = start_row;
thread_data[i].end_row = start_row + rows_per_thread + (i < extra_rows ? 1 : 0);
thread_data[i].num_cols = num_cols;
thread_data[i].matrix = m;
thread_data[i].vector = v;
thread_data[i].result = result;
pthread_create(&threads[i], NULL, pth_mat_vect, &thread_data[i]);
start_row = thread_data[i].end_row;
}
//等待所有线程完成
for (int i = 0; i < num_threads; i++){
pthread_join(threads[i], NULL);
}
printf("the matrix is : \n");
for (int i = 0; i < num_rows; i++) {
for (int j = 0; j < num_cols; j++) {
printf("%f ", m[i * num_cols + j]);
}
printf("\n");
}
printf("the vector is : \n");
for (int i = 0; i < num_cols; i++) {
printf("%d ", v[i]);
}
printf("\n");
// 输出结果
printf("the result is: \n");
for (int i = 0; i < num_rows; i++){
printf("%f ", result[i]);
}
printf("\n");
return 0;
}
3.4结果展示
总结
- 多线程编程过程:
- 创建线程
- 运行线程
- 停止线程结束(合并到主线程)
- 在创建线程中,值得注意的是要显式地启动线程并构造能够存储线程信息的数据结构。要分配相应的内存(如果使用动态申请内存的话,也可以直接使用数组定义)
- 线程函数,非常地amazing啊。只接受一个参数,所以该参数的类型需要自己定义,怎么在线程函数中使得其大放异彩(看矩阵乘法);其中还包括了其计算得到的结果
- 当所有线程运行完成之后,才能执行下一个命令。
参考文献
- 【团日活动】C++实现高性能并行计算——⑨pthreads并行编程