目录
知识点归纳
第4章 并行计算
并行性和并发性
并行算法只识别可并行执行的任务。CPU系统中,并发性是通过多任务处理来实现的。
通常,并行算法只识别可并行执行的任务,但是它没有规定如何将任务映射到处理组件。在理想情况下,并行算法中的所有任务都应该同时实时执行。然而,真正的并行执行只能在有多个处理组件的系统中实现,比如多处理器或多核系统。在单CPU系统中,一次只能执行一个任务。在这种情况下,不同的任务只能并发执行,即在逻辑上并行执行。在单CPU系统中,并发性是通过多任务处理来实现的
线程
1、线程的定义
线程是进程的基本执行单元,一个进程的所有任务都在线程中执行;
进程要想执行任务,必须得有线程,进程至少要有一条线程;
程序启动会默认开启一条线程,这条线程被称为主线程或UI线程;
2、进程的定义
进程是指在系统中正在运行的一个应用程序; 每个进程之间是独立的,每个进程均运行在其专用的且受保护的内存;
3、进程与线程的关系
l 地址空间:同一进程的线程共享本进程的地址空间,而进程之间则是独立的地址空间;
l 资源拥有:同一进程内的线程共享本进程的资源;如内存、I/O、CPU等,但是进程之间的资源是独立的;
l 健壮性:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃,整个进程都死掉。所以多进程要比多线程健壮;
l 进程切换时,消耗的资源大,效率高。所以涉及到频繁的切换时,使用线程要好于进程。同样如果要求同时进行并且又要共享某些变量的并发操作,只能用线程不能用进程;
l 执行过程:每个独立的进程有一个程序运行的入口、顺序执行序列和程序入口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制;
l 线程是处理器调度的基本单位,但进程不是;
- 用线程计算矩阵的和
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N 4
int A[N][N], sum[N];
void *func(void *arg)
{
int j, row;
pthread_t tid = pthread_self(); // get thread ID number
row = (int)arg; // get row number from arg
printf("Thread %d [%lu] computes sum of row %d\n", row, tid, row);
for (j=0; j<N; j++)
{
sum[row] += A[row][j];
}
printf("Thread %d [%lu] done sum[%d] = %d\n",row, tid, row, sum[row]);
pthread_exit((void*)0); // thread exit: 0=normal termination
}
int main (int argc, char *argv[])
{
pthread_t thread[N]; // thread IDs
int i, j, r, total = 0;
void *status;
printf("Main: initialize A matrix\n");
for (i=0; i<N; i++)
{
sum[i] = 0;
for (j=0; j<N; j++)
{
A[i][j] = i*N + j + 1;
printf("%4d" ,A[i][j]);
}
printf("\n");
}
printf("Main: create %d threads\n", N);
for(i=0; i<N; i++)
{
pthread_create(&thread[i], NULL, func, (void *)i);
}
printf("Main: try to join with threads\n");
for(i=0; i<N; i++)
{
pthread_join(thread[i], &status);
printf("Main: joined with %d [%lu]: status=%d\n",i, thread[i], (int)status);
}
printf("Main: compute and print total sum:");
for (i=0; i<N; i++)
{
total += sum[i];
}
printf("tatal = %d\n", total);
pthread_exit(NULL);
}
线程的优点
① 线程创建和切换速度更快
② 线程的响应速度更快
③ 线程更适合并行计算
线程的缺点
① 由于地址空间共享,线程需要来自用户的明确同步。
② 许多库函数可能对线程不安全,例如传统strtok()函数将一个字符串分成一连串令牌。通常任何使用全局变量或依赖于静态内存内容的函数.线程都不安全。为了使库函数 适应线程环境,还需要做大量的工作。
③ 在单CPU系统上,使用线程解决问题实际上要比使用顺序程序慢,这是由在运行 时创建线程和切换上下文的系统开销造成的。
线程同步
由于线程在进程的同一地址空间中执行,它们共享同一地址空间中的所有全局变量和数据结构。当多个线程试图修改同一共享变量或数据结构时,如果修改结果取决于线程的执行顺序,则称之为竞态条件。在并发程序中,绝不能有竞态条件。否则,结果可能不一致。除了连接操作之外,并发执行的线程通常需要相互协作。为了防止出现竞态条件并且支持线程协作,线程需要同步。通常,同步是一种机制和规则,用于确保共享数据对象的完整性和并发执行实体的协调性。
- 互斥量
最简单的同步工具是锁,它允许执行实体仅在有锁的情况下才能继续执行,在Pthread 中,锁被称为互斥量,意思是相互排斥。 - 死锁预防
互斥量使用封锁协议。如果某线程不能获取互斥量,就会被阻塞,等待互斥量解锁后再继续。在任何封锁协议中,误用加锁可能会产生一些问题。最常见和突出的问题是死锁。死锁是一种状态,在这种状态下,许多执行实体相互等待,因此都无法继续下去。
在这种情况下,T1和T2将永远相互等待,由于交叉加锁请求,它们处于死锁状态。与竞态条件类似,死锁决不能存在于并发程序中。有多种方法可以解决可能的死锁问题,其中包括死锁预防、死锁规避、死锁检测和恢复等。在实际系统中,唯一可行的方法是死锁预防,试图在设计并行算法时防止死锁的发生。一种简单的死锁预防方法是对互斥量进行排序,并确保每个线程只在一个方向请求互斥量,这样请求序列中就不会有循环。
但是,仅使用单向加锁请求来设计每个并行算法是不可能的。在这种情况下,可以使用条件加锁函数pthread_mutex_trylock()来预防死锁。如果互斥量已被加锁,则trylock()函数 会立即返回一个错误。在这种情况下,调用线程可能会释放它已经获取的一些互斥量以便进行退避,从而让其他线程继续执行。
- 用线程快速排序
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#define N 10
typedef struct{
int upperbound;
int lowerbound;
}PARM;
int A[N]={5,1,6,4,7,2,9,8,0,3};
int print() // print current a[] contents
{
int i;
printf("[ ");
for (i=0; i<N; i++)
{
printf("%d ", A[i]);
}
printf("]\n");
}
void *qsort_1(void *aptr)
{
PARM *ap, aleft, aright;
int pivot, pivotIndex, left, right, temp;
int upperbound, lowerbound;
pthread_t me, leftThread, rightThread;
me = pthread_self();
ap = (PARM *)aptr;
upperbound = ap->upperbound;
lowerbound = ap->lowerbound;
pivot = A[upperbound];
left = lowerbound - 1;
right = upperbound;
if (lowerbound >= upperbound)
pthread_exit(NULL);
while (left < right)
{
do { left++;} while (A[left] < pivot);
do { right--;}while (A[right] > pivot);
if (left < right )
{
temp = A[left];
A[left] = A[right];
A[right] = temp;
}
}
print();
pivotIndex = left;
temp = A[pivotIndex];
A[pivotIndex] = pivot;
A[upperbound] = temp; // start the "recursive threads"
aleft.upperbound = pivotIndex - 1;
aleft.lowerbound = lowerbound;
aright.upperbound = upperbound;
aright.lowerbound = pivotIndex + 1;
printf("%lu: create left and right threads\n", me);
pthread_create(&leftThread, NULL, qsort_1, (void *)&aleft);
pthread_create(&rightThread, NULL, qsort_1, (void *)&aright);// wait for left and right threads
pthread_join(leftThread, NULL);
pthread_join(rightThread, NULL);
printf("%lu: joined with left & right threads\n", me);
}
int main(int argc, char *argv[])
{
PARM arg;
int i, *array;
pthread_t me, thread;
me = pthread_self();
printf("main %lu: unsorted array =" ,me);
print();
arg.upperbound = N-1;
arg.lowerbound = 0;
printf("main %lu create a thread to do QS\n", me);
pthread_create(&thread, NULL, qsort_1, (void *)&arg); // wait for QS thread to finish
pthread_join(thread, NULL);
printf("main %lu sorted array = ", me);
print();
}
苏格拉底挑战
问题与解决方案
编程难度大:并行计算的编程模型通常比串行计算要复杂得多,需要考虑的问题也更多。建议从简单的例子入手,逐步掌握并行编程的基本技巧。
调试困难:由于并行计算涉及到多个处理器之间的交互,因此调试起来相对比较困难。建议使用专门的调试工具,如 PAPI、VTune 等,可以帮助我们定位问题所在。
硬件限制:并非所有的计算机都支持并行计算,而且即使支持,也可能因为硬件性能不足而导致实验效果不佳。建议尽量选择性能较好的机器进行实验。
编程语言的选择:不同的编程语言对于并行计算的支持程度不同,有的语言可能更适合进行并行计算。建议根据自己的需求选择合适的编程语言。
数据集的选择:不同的数据集对于并行计算的效果有很大影响,有时候即使是同样的算法,在不同的数据集上表现也会有所不同。建议选择合适的数据集进行实验。