深入理解计算机系统之十: 并发编程(4)

七、使用线程提高并行性

到目前为止,在对并发的研究中,我们都假设并发线程是在单处理器系统上执行的。然而,大多数现代机器具有多核处理器。并发程序通常在这样的机器上运行更快。因为操作系统内核在多个核上并行地调度这些并发线程,而不是在单个核上顺序地调度。在像繁忙的Web服务器、数据库服务器和大型科学计算机代码这样的应用中利用这样的并行性是至关重要的,而且在像Web浏览器、电子表格处理器程序和文档处理程序这样的主流应用中,并行性也变得越来越有用。

上图给出了顺序、并发和并行程序之间的集合关系。所有程序的集合能够被划分成不想交的顺序程序集合写顺序程序只有一条逻辑流。写并发程序有多条并发流。并行程序是一个运行在多个处理器上的并发程序。因此,并行程序的集合是并发程序的真子集。

注意:
(1)示例程序单线程顺序运行时非常慢,几乎比多线程并行运行时慢了一个数量级。
(2)使用的核数越多,性能越差。造成此原因是相对于内存更新操作的开销,同步操作(P和V)代价太大。
教训:
(1)同步开销巨大,要尽可能避免。如果无可避免,必须要用尽可能多的有用计算弥补这个开销。

 

八、其他并发问题

 

迄今为止。同步从根本上说是很难的问题,它引出了在普通的程序中不会出现的问题。

1、线程安全

当用线程编写程序时,必须小心地编写那些具有成为线程安全性 属性的函数。一个函数被称为线程安全的,当且仅当多个并发线程反复调用时,它会一直产生正确的结果。下面是定义出的4个不安全函数类:

 

(1)不保护共享变量的函数。
(2)保持跨越多个调用的状态的函数。
(3)返回指向静态变量的指针的函数。

 

(4)调用线程不安全函数的函数。

2、可重入性

有一类重要的线程安全函数,叫做可重入函数,其特点在于它们具有这样的属性:当它们被多个线程调用时,不会引用任何共享数据。下图展示了可重入函数、线程安全函数和线程不安全函数之间的集合关系。

3、在线程化的程序中使用已存在的库函数

大多数linux函数,包括定义在标准C库中的函数(如malloc、free、realloc、printf和scanf)都是线程安全的,只有一小部分是例外。

 

4、竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前 到达它的控制流中x点时,就会产生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹穿过执行状态空间,而忘记了另一条准则:多线程必须对任何可行的轨迹都正确工作。

(1)下面示例,主线程创建了四个对等线程,并传递一个指向一个唯一的整数ID的指针到每个线程。每个对等线程复制它的参数中传递的ID到一个局部变量中,然后输出一个包含这个ID的信息。看起来简单,但是运行程序后,会得到不正确的结果:

//
/* waring: this code is buggy */
#include  "csapp.h"

#define N 4

void *thread(void *vargp);

int main()
{
	pthread_t tid[N];
	int i;
	
	for (i = 0; i < N; i++)
		pthread_create(&tid[i], NULL, thread, &i);
	for (i = 0; i < N; i++)
		pthread_join(tid[i], NULL);
	exit(0);
}

/* thread routine */
void *thread(void *vargp)
{
	int myid = *((int *)vargp);
	printf("Hello from thread %d\n", myid);
	return NULL;
}
//

结果:

linux>./race
Hello from thread 1
Hello from thread 3
Hello from thread 2

Hello from thread 3

问题是由每个对等线程和主线程之间的竞争引起的。当主线程pthread_create函数创建一个对等线程,它传递一个指向本地栈变量i的指针。此时,竞争出现在下一次i++操作和对等线程中int myid = *((int *)vargp);间接引用和赋值之间。如果是主线程先进行了i++操作,后进行对等线程中int myid = *((int *)vargp);的间接引用和赋值。就等得到正确的ID。但是结果是不可控的。

(2)为了消除竞争,我们可以动态地为每个整数ID分配一个独立的块,并且传递给线程例程一个指向这个块的指针,并且注意释放这些块以免内存泄漏。

//
#include  "csapp.h"

#define N 4

void *thread(void *vargp);

int main()
{
	pthread_t tid[N];
	int i, *ptr;
	
	for (i = 0; i < N; i++) {
		ptr = malloc(sizeof(int));
		*ptr = i;
		pthread_create(&tid[i], NULL, thread, ptr);
	for (i = 0; i < N; i++)
		pthread_join(tid[i], NULL);
	exit(0);
}

/* thread routine */
void *thread(void *vargp)
{
	int myid = *((int *)vargp);
	free(vargp);
	printf("Hello from thread %d\n", myid);
	return NULL;
}
//

结果:

linux>./race
Hello from thread 0
Hello from thread 1
Hello from thread 2

Hello from thread 3

5、死锁
信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。如图展示了一对用两个信号量来实现互斥的线程的进程图。

(1)程序员使用P和V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹
线碰巧到达了死锁状态d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法
方向上的进展。换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的V操作。
(2)重叠的禁止区域引起了一组称为死锁区域的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,
那么死锁就是不可避免的了。轨迹线可以进入死锁区域,但是它们不可能离开。
(3)死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的

将会陷入这个区域。

注:程序死锁有很多原因,要避免死锁一般是很困难的。然而,当使用二元信号量来实现互斥时,可以使用下面简单而有效的规则来避免死锁:
(1)互斥锁加锁顺序规则:给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。

(2)如,我们可以通过这样的方法来解决上图中的死锁问题:在每个线程中先对s加锁,然后再对t加锁。得到下图。

 

致谢

1、《深入理解计算机系统》[第3版],作者 Randal E.Bryant, David R.O`Hallaron 译者 龚奕利 贺莲

 

 

 

展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 技术黑板 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读