《深入理解计算机系统》--并发编程

    这也是一本很出名的书,在很早的时候读过一些,这次从后面开始读,看有没有新的体会。

    如果逻辑流在时间上重叠,那么他们就是并发的,硬件异常处理程序、进程和UNIX信号处理程序都是熟悉的例子。并发现象不仅在内核中存在,在应用级别的程序中也存在。

  • 访问慢速的I/O设备

  • 与人交互

  • 通过推迟工作以降低延迟

  • 服务多个网络客户端

  • 在多核机器上进行并行计算

  • 进程

  • I/O多路复用

  • 线程

    其中后边三种是操作系统提供给应用程序的三种构造并发程序的方法。

一、基于进程的并发编程

    构造并发程序最简单的方法就是使用进程,使用fork()等一些列的函数。看一个使用进程并发的实例:

/* 
 * echoserverp.c - A concurrent echo server based on processes
 */
/* $begin echoserverpmain */
#include "csapp.h"
void echo(int connfd);

void sigchld_handler(int sig) 
{
    while (waitpid(-1, 0, WNOHANG) > 0)
	;
    return;
}

int main(int argc, char **argv) 
{
    int listenfd, connfd, port, clientlen=sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;

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

    Signal(SIGCHLD, sigchld_handler);
    listenfd = Open_listenfd(port);
    while (1) {
	connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
	if (Fork() == 0) { 
	    Close(listenfd); /* Child closes its listening socket */
	    echo(connfd);    /* Child services client */
	    Close(connfd);   /* Child closes connection with client */
	    exit(0);         /* Child exits */
	}
	Close(connfd); /* Parent closes connected socket (important!) */
    }
}
/* $end echoserverpmain */

    (有时候就禁不住吐槽一下,大师的代码就是这么帅,在最少的代码量的条件下完成的那么完善,结构那么严谨,让人看着那么舒服)

    首先是本地处于监听状态,然后去接受客户端的链接,一旦链接,父进程关闭创建的链接描述符,子进程关闭父进程的监听描述符。子进程处理操作,然后关闭链接。

    父子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但不共享用户地址空间。这是优点也是缺点:一个进程不可能不小心覆盖另一个进程的虚拟存储器;但是为了共享信息,他们必须使用显示的IPC机制,另一个缺点就是,他们往往比较慢,因为进程控制可IPC的开销很高。

    (ps:UNIX ipc通常指的是所有允许进程和同一台主机上其他进程进行通信的技术。其中包括管道、先进先出、系统V共享存储器以及系统V信号量,这些内容也就是还没有读的那本:UNIX网络编程卷二:进程间通信)

二、基于I/O多路复用的并发编程

    关于I/O多路复用技术,在别的书中已经介绍了很多,在这里只是给一个例子说明。select函数处理类型为fd_set的集合,也叫描述符集合。逻辑上,我们将描述符结合看成一个大小为n的位向量,每n位对应于描述符n,当且仅当第n位为1时,描述符n才表明是描述符集合的而一个元素。

    假设我们只考虑可读的描述符,select函数有两个输入:一个称为读集合的描述符集合和该读集合的技术n。select函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符k就表示准备好可以读了。作为一个副作用,select修改了参数fdset指向的fd_set,指明读集合中一个称为准备好集合的子集,这个集合是由读集合中准备好可以读了的描述符组成的。函数返回值指明了准备好集合的基数。由于这个副作用,每次调用select时都更新读集合。这个副作用在下面程序中的体现就是必须在while(1)循环中每次有描述符集合的重新复制,或者在while循环中存在FD_ZERO(&read_set),然后FD_SET(listenfd,&read_set).

    要理解这个函数的思想,是一个n位的向量。在向量的某一位代表的是相应的套接字描述符,当这个可读或者可写的时候,这个向量上对应的位变为0,说明这个描述符不再这个向量中,在再次循环的时候,需要将这个描述符再次添加到这个描述符集合中。

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

int main(int argc, char **argv) 
{
    int listenfd, connfd, port, clientlen = sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    fd_set read_set, ready_set;

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

    FD_ZERO(&read_set); 
    FD_SET(STDIN_FILENO, &read_set); 
    FD_SET(listenfd, &read_set); 

    while (1) {
	ready_set = read_set;
	Select(listenfd+1, &ready_set, NULL, NULL, NULL); 
	if (FD_ISSET(STDIN_FILENO, &ready_set))
	    command(); /* read command line from stdin */
	if (FD_ISSET(listenfd, &ready_set)) {
	    connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
	    echo(connfd); /* echo client input until EOF */
	}
    }
}

void command(void) {
    char buf[MAXLINE];
    if (!Fgets(buf, MAXLINE, stdin))
	exit(0); /* EOF */
    printf("%s", buf); /* Process the input command */
}
/* $end select */
    当然,这个函数只是展示了多路复用的思想,对于这个程序来说,一旦链接到某个客户端,就会连续会送输入行,直到客户端关闭这个连接中它的那一端。因此,如果你键入一个命令到标准输入,将不会得到相应,直到服务器和客户端之间结束。一个更好的方法是更细粒度的多路复用,服务器每次循环回送一个问本行。

三、基于线程的并发编程

    线程的并发编程是上述两种方式的混合,使用了多个逻辑流,但是在一个地址空间中,线程是运行在进程上下文中的逻辑流,线程由内核自动调度。每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器,通用目的寄存器和条件码。

    关于线程的相关可以参考:

    http://blog.csdn.net/yusiguyuan/article/details/12405953

    http://blog.csdn.net/yusiguyuan/article/details/12154823

    多个线程运行单一进程的上下文中,因此共享这个进程虚拟地址空间按的整个内容,包括他的代码、数据、堆、共享库和打开的文件。

    线程的执行有别与进程,一个线程上下文切换要比进程上下文切换快得多。和线程相关的还有线程的终止、回收已终止线程的资源、分离线程。

    在任何一个时间点上,线程是可结合的或者是可分离的。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源是没有被释放的。相反,一个分离的线程时不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放的。下面看一个例子:

/* 
 * 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, port, clientlen=sizeof(struct sockaddr_in);
    struct sockaddr_in clientaddr;
    pthread_t tid; 

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

    listenfd = Open_listenfd(port);
    while (1) {
	connfdp = 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;
}
/* $end echoservertmain */
    首先,在21~22行,在传递给线程的参数时,是每个进程自己分配的空间按,如果使用的是主线程分配的空间,那么这个线程接受到参数在下一个连接建立之后,就说明这个连接是错误的。

    同时分离了每个线程,这样保证线程在终止后会被系统自己回收资源。

    优秀的程序每句代码都透射着严谨!!

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

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

/* $begin sharing */
#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);
}
/* $end sharing */    
    然我们来分析下这个程序,创建两个线程,主线程传递一个唯一的ID给每个线程,每个线程利用这个ID输出一条个性化的信息,以及调用该线程例程的总次数。看看到底哪些是共享的,哪些是私用的,哪些是可以更改的。

五、用信号量同步线程

    共享变量非常方便,但是引入了同步错误的可能性。

/* 
 * badcnt.c - An improperly synchronized counter program 
 */
/* $begin badcnt */
#include "csapp.h"

#define NITERS 200000000
void *count(void *arg);

/* shared counter variable */
unsigned int cnt = 0;

int main() 
{
    pthread_t tid1, tid2;

    Pthread_create(&tid1, NULL, count, NULL);
    Pthread_create(&tid2, NULL, count, NULL);
    Pthread_join(tid1, NULL);
    Pthread_join(tid2, NULL);

    if (cnt != (unsigned)NITERS*2)
	printf("BOOM! cnt=%d\n", cnt);
    else
	printf("OK cnt=%d\n", cnt);
    exit(0);
}

/* thread routine */
void *count(void *arg) 
{
    int i;
    for (i = 0; i < NITERS; i++)
	cnt++;
    return NULL;
}
/* $end badcnt */

    这个错误会引发操作系统中很多很重要的问题:互斥、PV操作等等。

    可以参考这个

    无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的P和V操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者-消费者程序中有限缓冲区和读者-写者系统中的共享对象这样的资源访问进行调度。



    书中还有一段话值得回味,信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量s(初始为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二元信号量。因为它的值总是0或者1.以提供互斥为目的的二元信号量常常也称为互斥锁。

    除了提供互斥之外,信号量的另一个重要作用是调度对共享资源的访问。在这种场景下,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问题。

    PS:在开头想引用孔夫子的话:学而不思则罔,思而不学则殆!!觉得这句话对于CSer再合适不过,大概宽泛的想一下,计算机的技术太多太多,从上层应用下层开发,不管是那一块学精通都可以混一辈子饭吃!!但是总想什么都会还是不现实的,所以还是跟着自己的兴趣来,还有就是对一些东西必须去思考去理解!!!思考理解!!!!

你可能感兴趣的:(并发编程,深入理解计算机系统)