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

四、基于线程的并发编程

1、线程的简介

(1)多进程并发编程中,我们为每个流使用了单独的进程。内核会自动调度每个进程,而每个进程有自己的私有地址空间,这使得流共享数据很困难。I/O复用并发编程中,我们创建自己的逻辑流,并利用I/O多路复用显式地调度流。因为只有一个进程,

所有的流共享整个地址空间。线程是这两种方法的混合

(2)线程(thread)就是运行在进程上下文中的逻辑流线程由内核自动调度。每个线程都有它自己的线程上下文(thread contxt),包括唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的的寄存器和条件码所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间

(3)基于线程的逻辑流结合了基于进程和基于I/O多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数ID来识别线程。同基于I/O多路复用的流一样,多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的所有内容,包括它的代码、数据、堆、共享库和打开的文件。

2、线程执行模型


2、线程执行模型

多线程执行模型在某些方面个多进程的执行模式是相似的。如上图。每个进程开始生命周期时都是单一线程,这个线程称为主线程(main thread)。在某一时刻,主线程创建了一个对等线程(peer thread),从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用,例如read或者sleep,或者因为被系统的时间间隔计时器中断,控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,一次类推。在一些重要的方面,线程执行是不同于进程的。因为一个线程的上下文要比一个进程的上下文小很多,线程的上下文切换要比进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子进程来组织的。和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据

3、Posix 线程
Posix 线程(Pthreads)是在C程序中处理线程的一个标准接口。它最早出现在1995年,而且在所有的Linux系统上都可用。Pthread定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。(POSIX表示可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准)

//
#include “csapp.h”

woid *thread(void *vargp);

int main(int argc, char **argv)
{
	pthread_t tid;
	Pthread_create(&tid, NULL, thread, NULL);
	Pthread_join(tid, NULL);
	exit(0);
}

void *thread(void *vargp)	/* Thread routine */
{
	printf("Hello, world!\n");
	return NULL;
}
//

(1)上面展示了一个简单的Pthreads程序。主程序创建一个对等线程,然后等待它的终止。对等线程输出“Hello, world!\n”

并且终止。当主线程检测到对等线程终止后,它就通过调用exit终止该进程。线程的代码和本地数据被封装在一个线程例程(threa routine)中。

(2)正如函数void *thread(void *vargp);所示,每个线程例程都以一个通用指针作为输入,并返回一个通用指针。如果想传递多个参数给线程例程,应该将参数放到一个结构体中,并传递一个指向该结构的指针。相似地,如果想要线程例程返回多个参数,可以返回一个结构体的指着。

(3)pthread_t tid;主线程声明了一个本地变量tid,可以用来存放对等线程的ID。

(4)Pthread_create(&tid, NULL, thread, NULL); 主线程调用此函数创建一个新的对等线程。函数调用返回后,主线程和新的对等线程同时运行。

(5)Pthread_join(tid, NULL); 调用此函数,主线程等待对等线程终止。

(6)exit(0);调用此函数,终止当时运行在这个进程中的所有线程。

4、主要API的介绍

(1)创建线程pthread_create

//
#include <pthread.h>

typedef  void *(func)(void *);

int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
返回:成功返回0,出错返回非0
//
pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程。能用attr参数来改变

新创建线程的默认属性。在本书示例中,总是用一个为NULL的attr参数来调用pthread_self函数来获得它自己的线程ID。

//
#include <pthread.h>

pthread_t pthread_self(void);
返回调用者的线程ID。
//

(2)终止线程pthread_exit

一个线程终止的方式如下:

A、当顶层的线程例程返回时,线程会隐式终止

B、通过调用pthread_exit函数,线程会显式地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,返回值为thread_return.

//
#include <pthread.h>

void pthread_exit(void *thread_return);
//

A、某个对等线程调用Linux的exit函数,该函数终止进程以及所有与进程相关的线程。

B、另一个对等线程通过以当前线程ID作为参数调用pthread_cancel函数来终止当前线程。

//
#include  <pthread.h>

int pthread_cancel(pthread_t tid);
//

返回:成功返回0,出错返回非0

(3)回收已终止线程的资源pthread_join

线程通过调用pthread_join函数等待其他线程终止。

//
#include <pthread.h>

int pthread_join(pthread_t tid, void **thread_return);
返回:成功返回0,出错返回非0
//

pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的通用(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有的内存资源

注意,和Linux的wait函数不同,pthread_join函数只能等待一个指定的线程终止。没有办法让pthread_wait等待任意一个线程终止。这使得代码变得复杂,这是一个不合理的机制。

(4)分离线程pthread_detach

在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detache)。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放。默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用pthread_detach函数被分离。

//
#include <pthread.h>

int pthread_detach(pthread_t tid);
返回:成功返回0,出错返回非0
//

pthread_detach函数分离可结合线程tid.线程能够通过以pthread_self()为参数的pthead_detach调用来分离它们自己。在实际应用程序中,有很好的理由要使用分离的线程。例如,一个高性能Web服务器可能在每次收到web浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就没有必要显式地等待每个对等线程终止。此情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源了。

(5)初始化线程pthread_once

pthread_once函数允许你初始化与线程例程相关的状态。

//
#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
返回:总是0
//

once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当你第一次用参数once_conteol调用pthread_once时,它调用init_routine,这是一个没有输入参数、也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。无论何时,当你需要动态初始化多个线程贡献的全局变量时,pthread_once函数是很有用的。

5、基于线程的并发服务器

(1)代码

//
#include "csapp.h"

void echo(int connfd);
void *thread(void *vargp);

int main(int argc, char **argv)
{
	int listenfd, *connfdp
	socklen_t clientlen;
	struct sockaddr_storage clientaddr;
	pthread_t tid;
	
	if (argc != 2) {
		fprintf(stderr, "usage: %s <pott>\n", argv[0]);
		exit(0);
	}
	listenfd = open_listenfd(argv[1]);
	
	while (1) {
		clientlen = sizeof(struct sockaddr_storage);
		connfd = malloc(sizeof(int));
		*connfdp = accept(listenfd, (SA *) &clientaddr, &clientlen);
		pthread_create(&tid, NULL, thread, connfdp);
	}
}

/* Thread routine */
void *thread(void *vargp)
{
	int connfd = *((int *)vargp);
	pthread_detach(pthread_self());
	free(vargp);
	echo(connfd);
	close(connfd);
	return NULL;
}
//

(2)分析

上面是基于线程的并发echo服务器的代码。整体结构类似于基于进程的设计。主线程不断地等待连接请求,然后创建一个对等线程处理该请求。

A、pthread_create函数调用时,通过下面的语句实现将已连接描述符传递给对等线程(传递一个指向这个描述符的指针)

//
connfd = accept(listenfd, (SA *) &clientaddr, &clientlen);
pthread_create(&tid, NULL, thread, &connfd);
//

B、让对等线程间接引用这个指针,并将它赋值给一个局部变量,如下

//
void *thread(void *vargp) {
	int connfd = *((int *)vargp);
	.
	.
	.
}
//

C、然而,这样会出错,因为它在对等线程的赋值语句和主线程的accept语句间引入了竞争(race)。如果赋值语句在下一个accept之前完成,那么对等线程中的局部变量connfd就得到正确的描述符值。然而,如果赋值语句是在accept之后才完成,那么对等线程在同一个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将accept返回的每个已连接描述符分配到它自己的动态分配的内存块,具体语句如下:

//
connfd = malloc(sizeof(int));
*connfdp = accept(listenfd, (SA *) &clientaddr, &clientlen);
//

D、为了避免线程例程中内存泄漏。在不显式收回线程下,必须分离每个线程,使得它在终止时它的内存资源能够被收回。

pthread_detach(pthread_self());

E、必须小心释放主线程分配的内存块。

free(vargp);


五、多线程程序中的共享变量

1、介绍

程序中的变量是否共享,需要做如下判断:
(1)线程的基础内存模型是什么?
(2)根据这个模型,变量示例是如何映射到内存的?
(3)有多少线程引用这些实例?

一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。

2、线程内存模型

(1)一组并发线程运行在一个进程的上下文中每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合。

(2)从实际操作来说,让一个线程去读写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置。

(3)各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问。这里说的是通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分。示例中printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt);对等线程直接通过全局变量ptr间接引用主线程的栈的内容。

//
#include "csapp.h"

#define N 2

void *thread(void *vargp);

char **ptr;	/* global variable */

int main()
{
	int i;
	pthread_t tid;
	char *msgs[N] = {
		"Hello from foo",
		"Hello from bar"
	};
	
	ptr = msgs;
	for (i = 0; i < N; i++)
		pthread_create(&tid, NULL, thread, (void *)i);
	pthread_exit(NULL);
}

void *thread(void *vargp)
{
	int myid = (int)vargp;
	static int cnt = 0;
	printf("[%d]: %s(cnt = %d)\n", myid, ptr[myid], ++cnt);
	return NULL;
}
//

3、将变量映射到内存

多线程的C程序中变量根据它们的存储类型被映射到虚拟内存:

(1)全局变量。全局变量定义在函数之外,在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,在任何线程都可以引用。如上述代码中的ptr.当一个变量只有一个实例时,我们只用变量名来表示这个实例。

(2)本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程在执行同一个线程例程时也是如此。

(3)本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。例如上面代码thread函数中的static int cnt = 0;在运行时,虚拟内存的续/写区域中也只有一个cnt的实例。每个对等线程都读/写这个实例。

4、共享变量

一个变量共享,当且仅当它的一个实例被一个以上的线程引用。如上面代码中变量cnt是共享的,因为它只有一个运行实例,并且这个实例被两个对等线程引用。变量myid不是共享的,因为它的两个实例中每一个只被一个线程引用。


致谢

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




猜你喜欢

转载自blog.csdn.net/qq_38880380/article/details/80462816
今日推荐