线程并行编程
并行编程 和 串行编程
现在, 想使单个内核运行的更快已经很难, 但多添加一个内核要容易的多.
如果程序不能很好的利用多核, 他的运行速度和单核运行就有可能是一样的.
如果一个程序专门用于多核运算, 称为并行程序, 否则称为串行程序.
并行程序可以实现同时运行多个操作.
并行编程的三种方式
SIMD
单指令多数据, 常用于信号处理.
如, 两个数组相加, 同一指令( 加) 同时用于操作所有的数组元素.
MISD
多指令单数据, 多条指令对同一片数据进行操作, 同一数据被不同的执行线程同时处理.
如, 同时计算一组数据的 平均值 和 中位数.
MIMD
多指令多数据, 最常见的情况.
多条指令同时操作多片数据.
多任务处理
并行程序充分利用计算机的多核处理能力, 但并不意味着不能在单核处理器中运行, 因为操作系统.
操作系统是运行程序和内核之间的联系层, 提供了一层抽象, 保证每个程序都会及时得到机会运行各自的代码.
他会为每个程序提供一个短暂的时间区间( 通常是几毫秒), 此时程序可以在内核中运行.
这个时间区间后, 他会挂起程序, 让下一个程序运行, 这就给用户一种所有程序在同时运行的错觉( 这就是多任务处理).
为提高整体性能, 操作系统可能根据每个程序的运行情况, 改变时间区间的长度.
如, 程序读取文件数据等待硬盘响应时, 等待用户输入时, 等待网络时, 操作系统可以缩短该程序的时间区间, 保证其他程序的运行.
这种机制下, 程序在运行时无需知道系统内核的数量.
POSIX 线程
线程设计是并行程序设计的一种方法, 可以认为串行程序是单线程运行, 并行程序是多线程运行.
POSIX, Portable Operating System Interface, 大多数操作系统都支持的一种标准.
POSIX 线程, 也称 pthread.
典型的多线程程序由一个线程开始运行, 该线程称为主线程.
主线程调用 pthread_create 来产生其他线程.
如果系统有多个内核, 主线程和新线程可以同时运行.
对于单内核系统, 两个线程轮流运行.
主线程应该在第二个线程终止时调用 pthread_join.
该函数将使主线程处于等待状态, 直到另一个线程终止.
调用 pthread_join 之后, 将只有一个线程处于运行状态.
如果主线程没有等待新线程结束, 那么主线程将终止, 新线程同样被终止.
- /* thread.c */
- /*
- 编译时添加 -lpthread, 将 pthread 库与可运行程序连接
- **$ gcc thread.c -o thd -lpthread
- **$ ./thd
- Hello, world!
- ---- From Thread 2
- */
- #include <pthread.h>
- #include <stdio.h>
- #include <stdlib.h>
- void * printHello(void * arg) {
- int * pi = (int *) arg;
- int v = *pi;
- printf("Hello world!\n---- From Thread %i\n", v);
- return NULL;
- }
- int main(int argc, char ** argv) {
- pthread_t sec;
- int ret;
- int arg = 2;
- /*
- pthread_create 被调用, 第一个参数是 24 行中创建的 pthread_t 对象的地址
- 第二个参数是 NULL, pthread_t 对象 sec 将被初始化为默认值
- 第三个参数是 printHello 函数的地址
- 该函数的返回值类型是 void *, 有且仅有一个参数
- 线程通过该函数地址执行 16 ~ 21 行的 printHello 函数
- 第四个参数, 调用 printHello 函数时, 该参数被传递给 printHello 函数
- 此时, 有两个线程在并行运行
- */
- ret = pthread_create(&sec, NULL, printHello, (void *) &arg);
- if ( ret != 0 ) {
- fprintf(stderr, "Error: pthread_create() returned %i\n", ret);
- return EXIT_FAILURE;
- }
- /*
- 主线程调用 pthread_join 以等待第二线程运行直到结束
- 如果没有调用 pthread_join, 主线程可能终止, 并在第二线程打印信息前将第二线程终止
- */
- ret = pthread_join(sec, NULL);
- if ( ret != 0 ) {
- fprintf(stderr, "Error: pthread_join() returned %i\n", ret);
- return EXIT_FAILURE;
- }
- return EXIT_SUCCESS;
- }
多线程的交叉处理
- /* unsyn.c */
- #include <pthread.h>
- #include <stdio.h>
- #include <stdlib.h>
- #define THREADS 16
- void * thd_func(void * arg) {
- int * pint = (int *) arg;
- while ( 1 ) {
- (*pint)++;
- (*pint)--;
- if ( *pint != 0 ) {
- printf("value = %i\n", *pint);
- return NULL;
- }
- }
- }
- int main(int argc, char ** argv) {
- pthread_t thd_id[THREADS];
- int ret;
- int i;
- int arg = 0;
- for ( i = 0; i < THREADS; i++ ) {
- ret = pthread_create(&thd_id[i], NULL, thd_func, (void *) &arg);
- if ( ret != 0 ) {
- fprintf(stderr, "pthread_create() returned %i\n", ret);
- return EXIT_FAILURE;
- }
- }
- for ( i = 0; i < THREADS; i++ ) {
- ret = pthread_join(thd_id[i], NULL);
- if ( ret != 0 ) {
- fprintf(stderr, "pthread_join() returned %i\n", ret);
- return EXIT_FAILURE;
- }
- }
- return EXIT_SUCCESS;
- }
不同线程之间内存共享是线程编程的一个特性, 但偶尔会出问题.
unsyn.c 程序创建了几个线程, 这些线程共享同一个 int 型变量的地址.
试问, 线程有可能打印输出信息吗? 打印出的值是多少? 会不会返回 NULL 并终止呢?
该程序的运行无法预测.
操作系统提供给每个线程一个短暂的时间区间, 供其执行一些代码.
当程序有多个线程, 每个线程都享有一定的时间区间.
操作系统会根据很多因素的变化来决定是否将一个线程挂起, 让其他线程运行并取得一些进展.
然而, 并没有办法去确定一个线程什么时候被挂起.
操作系统负责管理所有线程, 并防止某个线程占用过多的处理器时间.
对于多核处理器, 成百上千个不同线程在不同时间运行.
产生的微秒级差异, 使得我们不可以根据一般方法来预测给定线程在给定时间运行的内容.
对于 unsyn.c, 程序在运行下面的指令时发生了什么?
(*pint)++;
- 读取 arg 的值
- 值加 1
- 将新值写入 arg
arg 的值只在第三步才改变, 前两步将 arg 的值暂时保存在 CPU 内部一个临时位置 ( 寄存器).
线程共享内存空间, 但不共享寄存器, 而操作系统可能在三步的任意一个地方将线程挂起.
两个线程交叉运行时, 几种可能的情况 ( 当然, 还存在别的可能)
可能过程 1 | 可能过程 2 | 可能过程 3 | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
线程 1 | 线程 2 | arg 值 | 线程 1 | 线程 2 | arg 值 | 线程 1 | 线程 2 | arg 值 | ||
读 arg | ---- | 0 | 读 arg | ---- | 0 | 读 arg | ---- | 0 | ||
arg 加 1 | ---- | 0 | arg 加 1 | ---- | 0 | arg 加 1 | ---- | 0 | ||
写 arg | ---- | 1 | 写 arg | ---- | 1 | 写 arg | ---- | 1 | ||
---- | 读 arg | 1 | ---- | 读 arg | 1 | 读 arg | ---- | 1 | ||
---- | arg 加 1 | 1 | ---- | arg 加 1 | 1 | arg 减 1 | ---- | 1 | ||
---- | 写 arg | 2 | ---- | 写 arg | 2 | ---- | 读 arg | 1 | ||
---- | 读 arg | 2 | ---- | 读 arg | 2 | ---- | arg 加 1 | 1 | ||
---- | arg 减 1 | 2 | ---- | arg 减 1 | 2 | ---- | 写 arg | 2 | ||
---- | 写 arg | 1 | ---- | 写 arg | 1 | 写 arg | ---- | 0 | ||
---- | 检查 arg | 1 | ---- | 检查 arg | 1 | ---- | 读 arg | 0 | ||
---- | 打印 arg | 1 | 读 arg | ---- | 1 | ---- | arg 减 1 | 0 | ||
arg 减 1 | ---- | 1 | ---- | 写 arg | -1 | |||||
写 arg | ---- | 0 | ---- | 检查 arg | -1 | |||||
---- | 打印 arg | 0 | ---- | 打印 arg | -1 |
线程设计者允许这样的情况, 并不是设计过程中的瑕疵.
交给操作系统管理所有线程, 可以更加有效的利用系统资源.
原子级操作( atomic)
计算机的最小操作, 不允许再被细分, 不能再被简化.
当有两个原子级操作申请同时提交时, 一个请求必须等待, 直到另一个请求结束其运行周期.
线程同步
当线程之间不共享内存或只共享只读内存时, 不会出现线程交叉运行带来的问题.
当多线程对同一片内存执行写入操作( 或读取再写入), 线程交叉运行将会带来一些问题.
进行程序测试时, 问题有时会出现, 有时又不出现.
如果意识到线程交叉运行会带来问题, 可以指定哪些操作必须为原子级的( 临界段).
这些操作不允许被交叉运行, 一次只有一个线程可以进入临界段, 线程之间必须是同步的.
这由互斥锁来实现, 互斥锁一般带有锁定和解锁语句, 锁定和解锁语句之间的代码就是临界段.
线程需要从操作系统获得钥匙才能进入临界段.
一次只有一个线程可以进入临界段, 当线程进入临界段就立刻上锁, 运行临界段代码时保留钥匙.
线程离开临界段时, 执行解锁并将钥匙归还操作系统.
如果线程想进入临界段又没有钥匙, 只能排队等待.
- /* syn.c */
- /*
- 该程序没有输出
- 所有线程将会不停的运行
- */
- #include <pthread.h>
- #include <stdio.h>
- #include <stdlib.h>
- #define THREADS 16
- typedef struct {
- int * pi;
- pthread_mutex_t * mlock;
- } ThdData;
- void * thd_func(void * arg) {
- ThdData * td = (ThdData *) arg;
- int * pi = td -> pi;
- pthread_mutex_t * mlock = td -> mlock;
- while ( 1 ) {
- int rv;
- /* 锁定 */
- rv = pthread_mutex_lock(mlock);
- /* 临界段起点 */
- if ( rv != 0 ) {
- fprintf(stderr, "Error: mutex_lock failed\n");
- return NULL;
- }
- (*pi)++;
- (*pi)--;
- if ( (*pi) != 0 ) {
- printf("Value is %i\n", *pi);
- return NULL;
- }
- /* 临界段终点 */
- rv = pthread_mutex_unlock(mlock);
- if ( rv != 0 ) {
- fprintf(stderr, "Error: mutex_unlock failed\n");
- return NULL;
- }
- }
- return NULL;
- }
- int main(int argc, char ** argv) {
- pthread_mutex_t mlock;
- pthread_mutex_init(&mlock, NULL);
- ThdData arg;
- int v = 0;
- arg.pi = &v;
- arg.mlock = &mlock;
- pthread_t thd_id[THREADS];
- int rv, i;
- for ( i = 0; i < THREADS; i++ ) {
- rv = pthread_create(&thd_id[i], NULL, thd_func, (void *) &arg);
- if ( rv != 0 ) {
- fprintf(stderr, "Error: pthread_create() failed\n");
- return EXIT_FAILURE;
- }
- }
- for ( i = 0; i < THREADS; i++ ) {
- rv = pthread_join(thd_id[i], NULL);
- if ( rv != 0 ) {
- fprintf(stderr, "Error: pthread_join() failed\n");
- return EXIT_FAILURE;
- }
- }
- pthread_mutex_destroy(&mlock);
- return EXIT_SUCCESS;
- }
阿姆达尔定律
多线程程序一般从主线程开始, 做一些工作, 然后其他线程被创建用于计算结果.
多线程的结果再被整合成最后的结果.
这样的程序编排有个明显的问题, 初始化和终结都是串行的, 这将成为性能瓶颈.
阿姆达尔定律, 递增的线程将带来递减的回报.
即是说, 程序总的运行时间随着线程数增加而递减, 但是线程数量翻倍却难以将总运行时间减少一半.