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

1.实现并发的3种方式

  • 如果逻辑控制流在时间上重叠,那么他们就是并发的concurrent,这种常见的现象称之为:并发concurrency。
  • 并发可看作是OS内核用来运行多个应用程序的机制,也可以开发应用级的并发程序concurrent program,OS提供的三种构造并发程序的方法:
    (1)进程:每个逻辑控制流都是一个进程,由内核来调度和维护;进程有独立的虚拟地址空间,所以进程间的通信需要使用IPC(进程间通信,interprocess communication,IPC)机制
    (2)I/O多路复用:程序是一个单独的进程,所有的流都共享同一个地址空间; 应用程序在一个进程的上下文中显示地调用其逻辑流;逻辑流被模型化为状态机,当数据到达文件描述符后,主程序显示地从一个状态转换为另一个状态;
    (3)线程:线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度; 其可看成是(1)和(2)的混合体,既可像进程流一样由内核进行调度,也像I/O多路复用一样共享同一个虚拟地址空间

2.基于进程的并发编程

  • 假设有2个客户端和一个服务器端,服务器正在监听一个监听描述符上的连接请求,eg:描述符3是监听描述符
第一步:服务器接受客户端的连接请求
此时,首先会返回一个已连接的描述符(比如描述符4)
接着,在接受连接请求之后,服务器会派生一个子进程,该子进程会获得服务器描述符表的完整副本,该子进程关闭它的副本中的监听描述符3,
父进程关闭它的已连接描述符4的副本,这样就得到了第二步中的图片


why?
因为父子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本是至关重要的,否则,将永远不会释放已连接
描述符4的文件表条目,而且由此引起的内存泄漏将最终消耗光可用的内存,使得系统崩溃。

在这里插入图片描述

第三步:假设父进程在为客户端1创建了子进程之后,它接受一个新的客户端2的连接请求,并返回一个新的已连接描述符(比如描述符5),
如图第三步图所示
第四步:然后,父进程又派生了另一个子进程,该子进程用已连接的描述符5位它的客户端提供服务,此时,父进程正在等待下一个
连接请求,而两个子进程正在并发地为它们各自的客户端提供服务

在这里插入图片描述

  • 基于进程的并发echo服务器,服务器会派生一个子进程来处理每个新的连接请求
#include "csapp.h"
void echo(int connfd);
/*
通常服务器会运行很长时间,所以用一个SIGCHLD来处理程序,来回收僵尸子进程。
因为当SIGCHLD处理程序时,SIGCHLD信号是阻塞地,而Linux信号是不排队的,所以SIGCHLD处理程序必须准备好回收多个僵尸子进程的
资源!
*/

void sigchld_handler(int sig) //line:conc:echoserverp:handlerstart
{
    while (waitpid(-1, 0, WNOHANG) > 0)
	;
    return;
} //line:conc:echoserverp:handlerend

int main(int argc, char **argv) 
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;

    if (argc != 2) {
	fprintf(stderr, "usage: %s <port>\n", argv[0]);
	exit(0);
    }

    Signal(SIGCHLD, sigchld_handler);
    listenfd = Open_listenfd(argv[1]);
    while (1) 
    {
		clientlen = sizeof(struct sockaddr_storage); 
		connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
		if (Fork() == 0) 
		{ 
			/*
			子进程要关闭监听fd
			*/
		    Close(listenfd); /* Child closes its listening socket */
		    echo(connfd);    /* Child services client */ //line:conc:echoserverp:echofun
		    Close(connfd);   /* Child closes connection with client */ //line:conc:echoserverp:childclose   见问题2,标记
		    exit(0);         /* Child exits */
		}
		Close(connfd); /* Parent closes connected socket (important!) */ //line:conc:echoserverp:parentclose
		/*
			父进程要关闭连接fd,以避免内存泄漏
		*/
    }
   	/*
   	因为套接字的文件表表项中引用计数直到父子进程的connfd都关闭了,那么客户端的连接才会终止
   	*/
}
/* $end echoserverpmain */

问题1:
父进程关闭了已连接描述符后,子进程仍然能够使用该描述符和客户端通信,why?
当父进程派生子进程时,它得到一个已连接描述符的副本,并将相关文件表中的引用计数从1增加到2.
当父进程关闭它的描述符副本时,引用计数就从2减少到1,。
因为内核不会关闭一个文件,直到文件表中它的引用计数值变为0,所以子进程这边的连接端将保持打开

问题2:若删除上面的代码(已经标记处),从没有内存泄漏的角度来讲,代码为啥正确?
因为当一个进程因为某种原因终止时,内核将关闭所有打开的描述符。
so,当子进程退出时,它的已连接文件描述符的副本也将被自动关闭。
  • 总结
    (1)优点:父子进程间共享状态信息:共享文件表,但是不共享用户地址空间。由于进程的地址空间是独立的,所以一个进程不可能不小心覆盖另一个进程的虚拟内存
    (2)缺点:独立的地址空间使得进程共享状态信息更加困难,为了共享信息,他们必须使用进程间通信IPC机制,
    IPC机制包括:管道,FIFO,系统V共享内存,系统V信号量semaphore

3.基于I/O多路复用的并发编程

  • I/O多路复用计数:
    (1)基本思想是:使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生后,才将控制返回给应用程序。

  • eg1:使用I/O多路复用的迭代echo服务器:服务器使用select等待监听描述符上的连接请求和标准输入上的指令,即:等待一组描述符准备好读
    (1)描述符集合:select函数处理类型为fd_set的集合,也叫做描述符集合。
    逻辑上,我们将描述符集看成一个大小为n的位向量:   b ( n 1 ) \ b_(n-1) ,…   b 1 \ b_1 ,   b 0 \ b_0 ,每个位   b k \ b_k 对应于描述符k。
      k \ k =1时,描述符k才表面是描述符集合中的一个元素。
    对描述符集可做的三件事情: 1)分配他们,2)将一个此种类型的变量赋值给另一个变量,3)使用FD_ZERO,FD_SET,FD_CLR,FD_ISSET宏来修改和检查他们
    (2)
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

/* $begin select */
#include "csapp.h"
void echo(int connfd);
void command(void);

int main(int argc, char **argv) 
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    fd_set read_set, ready_set;

    if (argc != 2) 
    {
		fprintf(stderr, "usage: %s <port>\n", argv[0]);
		exit(0);
    }
    /*
   		 如图1
    	Open_listenfd函数打开一个监听描述符
    	FD_ZERO创建一个空的读集合
    */
    listenfd = Open_listenfd(argv[1]);  //line:conc:select:openlistenfd

    FD_ZERO(&read_set);              /* Clear read set */ //line:conc:select:clearreadset

	/*
		如图2
		由描述符0(标准输入)和描述符3(监听描述符)组成的读集合
	*/
    FD_SET(STDIN_FILENO, &read_set); /* Add stdin to read set */ //line:conc:select:addstdin
    FD_SET(listenfd, &read_set);     /* Add listenfd to read set */ //line:conc:select:addlistenfd

    while (1) 
    {
		ready_set = read_set;
		/*
		1.int select(int n, fd_set *fdset,NULL,NULL.NULL)
		select有两个输入:一个称为读集合的描述符集合fdset,一个是读集合的基数n(任何描述符集合的最大基数)
		2.select会一直阻塞,直到读集合中至少有一个描述符准备好可以读
		3.当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符k就表示准备好可以读了。
		select有一个副作用,它修改参数fdset指向的fd_set,指明读集合中的一个子集,称为准备好集合ready set,该集合是由读集合中
		准备好可以读了的描述符组成。
		4.该函数返回的值指明了ready_set的基数,由于此副作用,我们必须在每次调用select时都更新读集合。
		5.如图3,select会一直阻塞至监听描述符或标准输入可读。
		若当用户按下回车键,此时使得标准输入描述符变为可读时,select会返回ready_set的值
		*/
		Select(listenfd+1, &ready_set, NULL, NULL, NULL); //line:conc:select:select
		if (FD_ISSET(STDIN_FILENO, &ready_set)) //line:conc:select:stdinready,如果是标准输入准备好了
		    command(); /* Read command line from stdin */
		if (FD_ISSET(listenfd, &ready_set)) //如果是监听描述符准备好了
		{ 
			//line:conc:select:listenfdready
	        clientlen = sizeof(struct sockaddr_storage); 
		    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
		    echo(connfd); /* Echo client input until EOF */
		    Close(connfd);
		}
    }
}

void command(void) {
    char buf[MAXLINE];
    if (!Fgets(buf, MAXLINE, stdin))
	exit(0); /* EOF */
    printf("%s", buf); /* Process the input command */
}
/* $end select */

问题:标准输入中输入ctrl+D表示EOF,当程序阻塞在对select的调用上时,输入ctrl+D会如何?
因为:若一个从描述符中读一个字节的请求不会阻塞,那么这个描述符就准备好可以读了。
假如EOF在一个描述符上为真,那么描述符也准备好可读了,因为读操作将立即返回一个零返回码!
所以,键入ctrl+D会导致select函数返回,准备好的集合中有描述符0
  • eg2:基于I/O多路复用的并发事件驱动的服务器
    (1)I/O多路复用可以用做并发事件驱动event-driven程序的基础,在事件驱动程序中,某些事件会导致流向前推进。
    一般的思路是将逻辑流模型化为状态机,一个状态机state machine就是一组状态state,输入事件input event和转移transition。

    通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,弧上的标号表示输入事件。
    一个状态机从某种状态开始执行,每个输入事件都会引发一个从当前状态到下一个状态的转移。
    (2)对于基于I/O多路复用的并发服务器而言,对于每个新的客户端k,其会创建一个新的状态机   S k \ S_k ,并将它和已连接描述符   d k \ d_k 联系起来。
    如下图所示:每个状态机   S k \ S_k 都有
    一个状态(等待描述符   d k \ d_k 准备好可读)
    一个输入事件(描述符   d k \ d_k 准备好可以读了)
    一个转移(从描述符   d k \ d_k 读一个文本行)

    so:服务器使用I/O多路复用,借助select函数检测输入事件的发生。当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一个文本行
    在这里插入图片描述
依据上述的状态机模型,可知:
select函数检测到输入事件
add_client函数创建一个新的逻辑流(状态机)
check_clients函数回送输入行,从而执行状态转移,而且当客户端完成本文行发送时,还需要删除这个状态机

/* 
 * echoservers.c - A concurrent echo server based on select
 */
/* $begin echoserversmain */
#include "csapp.h"

//pool结构里维护着活动客户端的集合
typedef struct 
{ 
/* Represents a pool of connected descriptors */ //line:conc:echoservers:beginpool
    int maxfd;        /* Largest descriptor in read_set */   
    fd_set read_set;  /* Set of all active descriptors */
    fd_set ready_set; /* Subset of descriptors ready for reading  */
    int nready;       /* Number of ready descriptors from select */   
    int maxi;         /* Highwater index into client array */
    int clientfd[FD_SETSIZE];    /* Set of active descriptors */
    rio_t clientrio[FD_SETSIZE]; /* Set of active read buffers */
} pool; //line:conc:echoservers:endpool


/* $end echoserversmain */
void init_pool(int listenfd, pool *p);
void add_client(int connfd, pool *p);
void check_clients(pool *p);


/* $begin echoserversmain */
int byte_cnt = 0; /* Counts total bytes received by server */

int main(int argc, char **argv)
{
    int listenfd, connfd;
    socklen_t clientlen;
    struct sockaddr_storage clientaddr;
    static pool pool; 

    if (argc != 2) 
    {
		fprintf(stderr, "usage: %s <port>\n", argv[0]);
		exit(0);
    }
    listenfd = Open_listenfd(argv[1]);
    init_pool(listenfd, &pool); //line:conc:echoservers:initpool

    while (1) 
    {
    	/*
    	select检测两种不同类型的输入事件:
    	1)来自一个新客户端的连接请求到达
    	2)一个已存在的客户端的已连接描述符准备好可以读了
    	*/
		/* Wait for listening/connected descriptor(s) to become ready */
		pool.ready_set = pool.read_set;
		pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);
	
		/* If listening descriptor ready, add new client to pool */
		/*
		当一个连接请求到达时,服务器打开连接,并调用add_client函数,并将该客户端添加到池里。
		最后,服务器调用check_clients函数,把来自每个准备好的已连接描述符的一个文本行会送回去
		*/
		if (FD_ISSET(listenfd, &pool.ready_set)) 
		{ 
			//line:conc:echoservers:listenfdready
	        clientlen = sizeof(struct sockaddr_storage);
		    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); //line:conc:echoservers:accept
		    add_client(connfd, &pool); //line:conc:echoservers:addclient
		}
	
		/* Echo a text line from each ready connected descriptor */ 
		check_clients(&pool); //line:conc:echoservers:checkclients
    }
}
/* $end echoserversmain */

/* $begin init_pool */
//初始化客户端池
void init_pool(int listenfd, pool *p) 
{
    /* Initially, there are no connected descriptors */
    int i;
    p->maxi = -1;                   //line:conc:echoservers:beginempty
    //clientfd数组表示:已连接描述符的集合,-1表示一个可用的槽位,初始化时,已连接描述符集合是空的;
    for (i=0; i< FD_SETSIZE; i++)  
		p->clientfd[i] = -1;        //line:conc:echoservers:endempty

    /* Initially, listenfd is only member of select read set */
    //监听描述符是select读集合中唯一的描述符
    p->maxfd = listenfd;            //line:conc:echoservers:begininit
    FD_ZERO(&p->read_set);
    FD_SET(listenfd, &p->read_set); //line:conc:echoservers:endinit
}
/* $end init_pool */

/* $begin add_client */
//添加一个新的客户端到活动客户端池中
void add_client(int connfd, pool *p) 
{
    int i;
    p->nready--;
    //在clientfd数组中找到一个空槽位后,服务器将这个已连接描述符添加至数组中,并初始化相应的RIO读缓冲区,
    //这样一来,就可以对该描述符调用Rio_readlineb
    for (i = 0; i < FD_SETSIZE; i++)  /* Find an available slot */
    {
	    if (p->clientfd[i] < 0) 
		{ 
		    /* Add connected descriptor to the pool */
		    p->clientfd[i] = connfd;                 //line:conc:echoservers:beginaddclient
		    Rio_readinitb(&p->clientrio[i], connfd); //line:conc:echoservers:endaddclient
			
			//将该已连接描述符添加到select读集合,并更新该池的一些全局属性
		    /* Add the descriptor to descriptor set */
		    FD_SET(connfd, &p->read_set); //line:conc:echoservers:addconnfd
	
			//maxfd变量记录了select的最大文件描述符
			//maxi变量记录的是到clientfd数组的最大索引,这样check_clients函数就不用搜索整个数组了
		    /* Update max descriptor and pool highwater mark */
		    if (connfd > p->maxfd) //line:conc:echoservers:beginmaxfd
				p->maxfd = connfd; //line:conc:echoservers:endmaxfd
		    if (i > p->maxi)       //line:conc:echoservers:beginmaxi
				p->maxi = i;       //line:conc:echoservers:endmaxi
		    break;
		}
    }
	
    if (i == FD_SETSIZE) /* Couldn't find an empty slot */
		app_error("add_client error: Too many clients");
}
/* $end add_client */

/* $begin check_clients */
//回送来自每个准备好的已连接描述符的一个文本行。
void check_clients(pool *p) 
{
    int i, connfd, n;
    char buf[MAXLINE]; 
    rio_t rio;

    for (i = 0; (i <= p->maxi) && (p->nready > 0); i++) 
    {
		connfd = p->clientfd[i];
		rio = p->clientrio[i];
	
		/* If the descriptor is ready, echo a text line from it */
		if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) 
		{ 
		    p->nready--;
		    if ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) 
		    {
		    	//如果成功地从描述符读取了一个文本行,那么就将该文本回送到客户端
		    	//byte_cnt表示:从所有客户端收到的全部字节的累计值
				byte_cnt += n; //line:conc:echoservers:beginecho
				printf("Server received %d (%d total) bytes on fd %d\n", 
				       n, byte_cnt, connfd);
				Rio_writen(connfd, buf, n); //line:conc:echoservers:endecho
		    }
		    //若客户端关闭该连接中的那一端,检测到EOF,那么将关闭这边的连接端,并从池中清除掉该描述符
		    /* EOF detected, remove descriptor from pool */
		    else 
		    { 
				Close(connfd); //line:conc:echoservers:closeconnfd
				FD_CLR(connfd, &p->read_set); //line:conc:echoservers:beginremove
				p->clientfd[i] = -1;          //line:conc:echoservers:endremove
		    }
		}
    }
}
/* $end check_clients */


  • 总结
  • 优点:
    (1)现代高性能服务器Node.js,nginx,Tornado都是基于I/O多路复用的事件驱动的编程方式!
    (2)事件驱动设计优点:
    优点1:他比基于进程的设计给了程序员更多的对程序行为的控制;
    优点2:一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此,每个逻辑流都能访问该进程的全部地址空间。
    优点3:一个与作为单个进程运行相关的优点是:可以利用gdb像顺序程序一样,调试并发服务器;
    优点4:事件驱动设计要比基于进程的设计要高效的多,因为不需要进程上下文切换来调度新的流
    (3)事件驱动设计缺点:
    缺点1:编码复杂,比基于进程的服务器的代码量大三倍,随着并发粒度的减小,复杂度还会上升。
    eg:并发粒度是:读一个完整的文本行所需要的指令数量,只要某个逻辑流正忙于读一个文本行,其他逻辑流就不可能有进展。上面的eg不存在该问题,但是它使得在“故意只发送部分文本行,然后停止”的恶意客户端的攻击面前,该事件驱动服务器闲得很脆弱。
    缺点2:不能充分利用多核处理器。

4.基于线程的并发编程

  • 线程thread就是运行在进程上下文中的逻辑流。
    (1)程序都是由每个进程中的一个线程组成。一个进程里同时可以运行多个线程的程序。线程由内核自动调度
    (2)每个线程都有自己的线程上下文(thread context),包括:一个唯一的线程ID(Thread id,TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。所有运行在一个进程里的线程共享该进程的整个虚拟地址空间。(包括:它的代码,数据,堆,共享库和打开的文件)

  • 主线程和对等线程
    (1)每个进程开始生命周期时,都是单一线程,该线程称之为主线程(main thread)。在某一时刻,主线程会创建一个对等线程(peer thread),从该时间点开始,两个线程就并发地运行
    (2)主线程和其他线程的区别仅在于:它总是进程中第一个运行的线程。
    对等(线程)池的主要影响是:一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止,此外,每个对等线程都能读写相同的共享数据。
    在这里插入图片描述

  • Posix线程


 * hello.c - Pthreads "hello, world" program 
 */
/* $begin hello */
#include "csapp.h"
void *thread(void *vargp);                    //line:conc:hello:prototype

//主线程代码开始
int main()                                    //line:conc:hello:main
{
    pthread_t tid;                            //line:conc:hello:tid
    
    //主线程通过调用pthread_creat函数创建了一个新的对等线程,当其返回时,主线程和新创建的对等线程同时运行,tid包含新线程的ID
    Pthread_create(&tid, NULL, thread, NULL); //line:conc:hello:create
    
    //Pthread_join,主线程等待对等线程终止。
    Pthread_join(tid, NULL);                  //line:conc:hello:join

	//主线程调用exit,终止当前运行在该进程中的所有线程
    exit(0);                                  //line:conc:hello:exit
}

//定义了对等线程的例程
//每个例程都以一个通用指针作为输出,并返回一个通用指针,若想想传递多个参数给线程的例程,那就应该将参数放到一个结构中,
//并传递一个指向该结构的指针
void *thread(void *vargp) /* thread routine */  //line:conc:hello:beginthread
{
    printf("Hello, world!\n");                 
    return NULL;                               //line:conc:hello:return
}                                              //line:conc:hello:endthread
/* $end hello */

  • 在任何一个时间点上,线程是可结合(joinable)或者是分离的(detached)。
    (1)一个可结合的线程能够被其他线程收回和杀死,在被其他线程回收之前,它的内存(eg:栈)是不释放的
    (2)一个分离的线程是不能被其他线程回收或者杀死的。它的内存资源在它终止时,由系统自动释放。
    eg:在一个高性能的Web服务器中,因为每个连接都是一个单独的线程独立处理的,所以没必要使用pthread_exit,pthread_cancel,pthread_join函数显示地等待每个对等线程终止。
    这种情况下,每个对等线程都应该在它开始处理请求之前分离他自身,这样就不能在他终止后回收它的内存资源。
    ps:
    pthread_exit:主线程调用,会等待其他对等线程终止,最后终止主线程和整个进程
    pthread_cancel:终止当前线程
    pthread_join:一直阻塞到其他线程终止,再回收已终止线程占用的所有内存资源

  • eg:基于线程的并发echo服务器
    思想:主线程不断地等待连接请求,然后常创建一个对等线程处理该请求

如何将已连接描述符传递给对等线程??
传递一个指向该描述符的指针即可

confd=Accept(listenfd, (SA *)&clientaddr, &clientlen);
Pthread_creat(&tid, NULL. thread, &connfd);
接着,然对等线程间接引用该指针,并将其赋值给另一个局部变量
void *thread(void *vargp)
{
	int connfd=*((int *)vargp);
	,,,
}

/* 
 * echoservert.c - A concurrent echo server using threads
 */
/* $begin echoservertmain */
#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 <port>\n", argv[0]);
		exit(0);
    }
    listenfd = Open_listenfd(argv[1]);
	
	/*
	为了避免主线程和对等线程的致命竞争,这里将accept返回的每个已连接描述符分配到它自己的Malloc的动态分配的内存块中,why?
	
	因为在对等线程的赋值语句和主线程的accept语句之间引入了竞争(race)。
	若赋值语句在下一个accept之前完成,那么对等线程中的局部变量connfd就得到正确的描述符值;
	若赋值语句在下一个accept之后才完成的,那么对等线程中的connfd就得到的是下一次连接的描述符值
	*/
    while (1) 
    {
        clientlen=sizeof(struct sockaddr_storage);
		connfdp = Malloc(sizeof(int)); //line:conc:echoservert:beginmalloc
		*connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); //line:conc:echoservert:endmalloc
		/*
		注意C语言的指针的传递的写法
		*/
		Pthread_create(&tid, NULL, thread, connfdp);
    }
}

/* Thread routine */
void *thread(void *vargp) 
{  
    int connfd = *((int *)vargp);
    	/*
	对等线程如何避免内存泄漏问题?
	既然不显示地回收线程,就必须分离每个线程,使得他在终止时,他的内存能够被回收
	*/
    Pthread_detach(pthread_self()); //line:conc:echoservert:detach
	
    /*
    下面的free释放的是主线程分配的内存块
    */
    Free(vargp);                    //line:conc:echoservert:free

    echo(connfd);
    Close(connfd);
    return NULL;
}
/* $end echoservertmain */

问题1:在基于进程的服务器中,我们在两个位置关闭了已连接描述符:父进程和子进程。而在这里的基于线程的服务器中,我们只在
一个位置关闭了已连接的描述符:对等线程,why?

因为线程运行在同一个进程中,他们共享相同的描述符表。无论有多少线程使用该已连接描述符,这个已连接描述符的文件表的引用计数都是1。
so,当我们用完它时,一个close操作就足以释放与这个已连接描述符相关的内存资源了
  • 多线程程序中的共享变量
    eg:由一个创建了两个对等线程的主线程组成。 主线程传递一个唯一地ID给每个对等线程,每个对等线程利用这个ID输出一条个性化的信息,以及调用该线程例程的总次数。
/* $begin sharing */
#include "csapp.h"
#define N 2
void *thread(void *vargp);

char **ptr;  /* Global variable */ //line:conc:sharing:ptrdec

int main() 
{
    int i;  
    pthread_t tid;
	
	//msgs这样的本地自动变量也能被共享
    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) 
{
	/*
	1.cnt是共享变量,只有一个运行时实例,该实例被两个对等线程引用。
	2.myid不是共享的,因为它的两个实例中每一个都被一个线程引用。
	*/
    int myid = (int)vargp;
    static int cnt = 0; //line:conc:sharing:cntdec
    
    //ptr[myid]:这里的对等线程直接通过全局变量ptr间接引用了主线程栈的内容
    printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt); //line:conc:sharing:stack
    return NULL;
}
/* $end sharing */1)一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括:线程ID,栈,栈指针,程序计数器,条件码和
通用目的寄存器。

(2)各自独立的线程栈都是被保存在虚拟地址空间的栈区域中的,但是被相应的线程独立的访问。
每个线程和其他线程一起共享进程上下文的剩余部分,包括:整个用户虚拟地址空间(由:只读文本(代码),读/写数据,堆以及所有的
共享库和数据区域组成的,打开的文件的集合)3)寄存器是从不共享的,而虚拟内存总是共享的。

(4)不同的线程栈是不对其他线程设防的,若一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分

(5)将变量依据其存储类型映射到虚拟内存中
全局变量:全局变量是定义在函数之外的变量。
在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用

本地自动变量:本地自动变量就是定义在函数内部,但是没有static属性的变量。
在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。
eg:本地变量myid有两个实例,一个在对等线程0的栈内,另一个在对等线程1的栈内。我们将这两个实例分别表示为myid.p0和myid.p1.

本地静态变量:本地静态变量是定义在函数内部并有static属性的变量。
和全局变量一样,虚拟内存的读/写区域中只包含在程序中声明的每个本地静态变量的一个实例。每个对等线程都读和写这个实例。

(6)共享变量:一个变量v是共享的,当且仅当它的一个实例被一个以上的线程所引用。

问题:分析一下上面代码的变量,最终的表格如下
符号v.t表示变量v的一个实例,它驻留在线程t的本地栈中,其中t要么是m(主线程),要么是po(对等线程0),或者p1(对等线程1)
变量实例   		被主线程引用             被对等线程0引用             被对等线程1引用
ptr					是							是						是
cnt					否							是						是
i.m					是 							否						否
msg.m				是							是						是
myid.po				否							是						否
myid.p1				否							否						是

ptr:一个被主线程写和被对等线程读的全局变量
cnt:一个静态变量,在内存中只有一个实例,被两个对等线程读和写
i.m:一个存储在主线程栈中的本地自动变量。虽然它的值被传递给对等线程,但是对等线程也绝不会在栈中引用它,因此,他不是共享的!!!
msg.m:一个存储在主线程栈中的本地自动变量,被两个对等线程通过ptr间接地引用。
myid.0和myid.1:一个本地自动变量的实例,分别驻留在对等线程0和对等线程1的栈中。
变量ptr,cnt,msgs被多于一个线程引用,因此他们是共享的。
  • 用信号量同步线程
/* 
 * badcnt.c - An improperly synchronized counter program 
 */
/* $begin badcnt */
/* WARNING: This code is buggy! */
#include "csapp.h"

void *thread(void *vargp);  /* Thread routine prototype */

/* Global shared variable */
volatile long cnt = 0; /* Counter */

int main(int argc, char **argv) 
{
    long niters;
    pthread_t tid1, tid2;

    /* Check input argument */
    if (argc != 2) 
    { 
	    printf("usage: %s <niters>\n", argv[0]);
	    exit(0);
    }
    niters = atoi(argv[1]);

    /* Create threads and wait for them to finish */
    Pthread_create(&tid1, NULL, thread, &niters);
    Pthread_create(&tid2, NULL, thread, &niters);
    Pthread_join(tid1, NULL);
    Pthread_join(tid2, NULL);

    /* Check result */
    if (cnt != (2 * niters))
	    printf("BOOM! cnt=%ld\n", cnt);
    else
	    printf("OK cnt=%ld\n", cnt);
    
    exit(0);
}

/* Thread routine */
void *thread(void *vargp) 
{
    long i, niters = *((long *)vargp);

    for (i = 0; i < niters; i++) //line:conc:badcnt:beginloop
	    cnt++;                   //line:conc:badcnt:endloop

    return NULL;
}
/* $end badcnt */

执行:
./bad 1000000
BOOM! cnt=1445085

./bad 1000000
BOOM! cnt=1915220

./bad 1000000
BOOM! cnt=1404746

结论:
(1)不仅得到错误的答案,而且每次得到的答案还都不同,原因是:一般情况下,没有办法预测操作系统是否能将为你的线程选择一个
正确的顺序

(2)如下图1所示,对计数器的解释
for (i = 0; i < niters; i++) //line:conc:badcnt:beginloop
 	cnt++;                   //line:conc:badcnt:endloop

H i H_i :在循环头部的指令块
L i L_i :加载共享变量cnt到累加寄存器% r d x i rdx_i 的指令,这里的% r d x i rdx_i 标识线程i中的寄存器% r d x rdx 的值
U i U_i :更新(增加)% r d x i rdx_i 的指令
S i S_i :将% r d x i rdx_i 的更新值存回到共享变量cnt的指令
T i T_i :循环尾部的指令块
注意:头部和尾部只操作本地栈变量,而 L i L_i U i U_i S i S_i 是操作共享计数器变量的内容。
在一个单处理器上并发运行两个对等线程,并发执行的两个线程中的指令会以某种全序列(或者交叉)来完成。
在这里插入图片描述

3)进度图
每条轴k对应于线程k的进度;
每个点代表线程k已经完成了指令Ik这一状态;
转换被表示一条从一点到相邻点的有向边;
合法的转换是:
向右:线程1中的一条指令完成;
向上:线程2中的一条指令完成;
两条指令不能在同一时刻完成——对角线转换时不允许的!!
反向,向下,向左也是不合法的!!

eg:指令的顺序:H1,L1,U1,H2,L2,S1,T1,U2,S2,T2
对应的轨迹线如下图2所示,
说明:对于线程i,操作共享变量cnt内容的指令(Li,Ui,Si)构成了一个临界区,该临界区不应该和其他进程的临界区交替执行!!
即:要确保每个线程在执行它的临界区中的指令时,要拥有对共享变量的互斥的访问!!

在这里插入图片描述

4)不安全区与安全区
不安全区:两个临界区的交集形成的状态空间区域称之为:不安全区。
如下图3所示变量cnt的不安全区,状态(H1,H2)(S1,U2)毗邻不安全区,但是他们并不是不安全区的一部分,下面
的轨迹穿越不安全区,所以是不安全的。

在这里插入图片描述

参考:<深入理解计算机系统(第三版)>

发布了510 篇原创文章 · 获赞 134 · 访问量 15万+

猜你喜欢

转载自blog.csdn.net/u011436427/article/details/103870300
今日推荐