学习笔记7

发布时间 2023-10-28 21:10:23作者: LLLZT

知识点归纳

第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 等,可以帮助我们定位问题所在。
硬件限制:并非所有的计算机都支持并行计算,而且即使支持,也可能因为硬件性能不足而导致实验效果不佳。建议尽量选择性能较好的机器进行实验。
编程语言的选择:不同的编程语言对于并行计算的支持程度不同,有的语言可能更适合进行并行计算。建议根据自己的需求选择合适的编程语言。
数据集的选择:不同的数据集对于并行计算的效果有很大影响,有时候即使是同样的算法,在不同的数据集上表现也会有所不同。建议选择合适的数据集进行实验。

实践过程