在本章的学习中,咱们的学习目标以下:程序员
并发的概念:数据库
若是逻辑控制流在时间上是重叠的,那么它们就是并发的。编程
应用级并发的应用:安全
现代操做系统中三种构造并发程序的方法:服务器
线程与进程的区分:网络
返回目录多线程
构造并发编程最简单的方法就是用进程,使用那些你们都很熟悉的函数,像fork、exec和waitpid。并发
步骤:app
四个步骤的示意图以下:socket
注意:子进程关闭监听描述符和父进程关闭已链接描述符是很重要的,由于父子进程共用同一文件表,文件表中的引用计数会增长,只有当引用计数减为0时,文件描述符才会真正关闭。因此,若是父子进程不关闭不用的描述符,将永远不会释放这些描述符,最终将引发存储器泄漏而最终消耗尽能够的存储器,是系统崩溃。
使用进程并发编程要注意的问题:
进程的优劣:
对于在父、子进程间共享状态信息,进程有一个很是清晰的模型:共享文件表,可是不共享用户地址空间。进程有独立的地址控件爱你既是优势又是缺点。因为独立的地址空间,因此进程不会覆盖另外一个进程的虚拟存储器。可是另外一方面进程间通讯就比较麻烦,至少开销很高。
编写的代码以下:
#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!) */ } }
面对困境——服务器必须响应两个互相独立的I/O事件:
针对这种困境的一个解决办法就是I/O多路复用技术。
I/O多路复用技术基本思想是:
可使用select、poll和epoll来实现I/O复用。使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生后,才将控制返给应用程序。
select函数以下图所示:
使用select函数的过程以下:
基于i/o多路复用的并发事件驱动服务器:
I/O多路复用能够用作并发事件驱动程序的基础,在事件驱动程序中,流是由于某种事件而前进的,通常概念是将逻辑流模型化为状态机,不严格地说,一个状态机就是一组状态,输入事件和转移,其中转移就是将状态和输入事件映射到状态,每一个转移都将一个(输入状态,输入事件)对映射到一个输出状态,自循环是同一输入和输出状态之间的转移,一般把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输人事件,一个状态机从某种初始状态开始执行,每一个输入事件都会引起一个从当前状态到下一状态的转移,对于每一个新的客户端k,基于I/O多路复用的并发服务器会建立一个新的状态机S,并将它和已链接描述符d联系起来。
I/O多路复用技术的优势:
使用事件驱动编程,这样比基于进程的设计给了程序更多的对程序行为的控制。
一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,所以每一个逻辑流都访问该进程的所有地址空间。这使得在流之间共享数据变得很容易。一个与做为单进程运行相关的优势是,你能够利用熟悉的调试工具,例如GDB来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计经常比基于进程的设计要高效不少,由于它们不须要进程上下文切换来调度新的流。
缺点:
事件驱动设计的一个明星的缺点就是编码复杂。咱们的事件驱动的并发服务器须要比基于进程的多三倍。不幸的是,随着并发粒度的减少,复杂性还会上升。这里的粒度是指每一个逻辑流每一个时间片执行的指令数量。
基于事件的设计的另外一重大的缺点是它们不能充分利用多核处理器。
编写的代码以下:
#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 */ }
在使用进程并发编程中,咱们为每一个流使用了单独的进程。内核会自动调用每一个进程。每一个进程有它本身的私有地址空间,这使得流共享数据很困难。在使用I/O多路复用的并发编程中,咱们建立了本身的逻辑流,并利用I/O多路复用来显式地调度流。由于只有一个进程,全部的流共享整个地址空间。而基于线程的方法,是这两种方法的混合。
线程执行模型:
线程和进程的执行模型有些类似。每一个进程的声明周期都是一个线程,咱们称之为主线程。线程是对等的,主线程跟其余线程的区别就是它先执行。
线程就是运行在进程上下文的逻辑流,以下图所示。线程由内核自动调度。每一个线程都有它本身的线程上下文,包括一个惟一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。全部的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于线程和基于I/O多路复用的流的特性。同进程同样,线程由内核自动调度,而且内核经过一个整数ID来标识线程。同基于I/O多路复用的流同样,多个线程运行在单一进程的上下文中,所以共享这个线程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
posix线程
POSIX线程是在C程序中处理线程的一个标准接口。它最先出如今1995年,并且在大多数Unix系统上均可用。Pthreads定义了大约60个函数,容许程序建立、杀死和回收线程,与对等线程安全地共享数据,还能够通知对等线程系统状态的变化。
建立线程:
pthread_create函数用来建立其余进程。
pthread_create函数建立一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新建立线程的默认属性。
当pthread_create返回时,参数tid包含新建立线程的ID。
获取自身ID:
pthread_self函数用来获取自身ID。
终止线程:
一个线程是如下列方式之一来终止的:
pthread_exit函数和ptherad_cancel函数函数以下所示:
回收已终止线程的资源:
pthread_join函数会终止,直到线程tid终止。和wait不一样,该函数只能回收指定id的线程,不能回收任意线程。
** 分离线程:**
在任何一个时间点上,线程是可结合的或者是分离的。一个可结合的线程可以被其余线程收回其资源和杀死。在被其余线程回收以前,它的存储器资源(例如栈)式没有被释放的。相反,一个分离的线程是不能被其余线程回收和杀死的。它的存储器资源在它终止时由系统自动释放。
默认状况下,线程被建立成可结合的。为了不存储器泄漏,每一个可结合线程都应该要么被其余线程显式地收回,要么经过调用pthread_detach函数被分离。
pthread_detach函数分离可结合线程tid。线程可以经过以pthread_self()为参数的pthread_detach调用来分离它们本身。
初始化线程:
pthread_once()函数用来初始化多个线程共享的全局变量。
编写代码以下:
/* * 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; }
每一个线程都有它本身独自的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每一个线程和其余线程一块儿共享进程上下文的剩余部分。寄存器是从不共享的,而虚拟存储器老是共享的。线程化的c程序中变量根据它们的存储器类型被映射到虚拟存储器:全局变量,本地自动变量(不共享),本地静态变量。
线程存储器模型:
一组并发线程运行在一个进程的上下文中。每一个线程都有它本身独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每一个线程和其余线程一块儿共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本代码、读/写数据、堆以及全部的共享库代码和数据区域组成的。线程也共享一样的打开文件的集合。
从实际操做的角度来讲,让一个线程去读或写另外一个线程的寄存器值是不可能的。另外一方面,任何线程均可以访问共享虚拟存储器的任意位置。若是某个线程修改了一个存储器位置,那么其余每一个线程最终都能在它读这个位置时发现这个变化。所以,寄存器是从不共享的,而虚拟存储器老是共享的。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,而且一般是被相应的线程独立地访问的。咱们说一般而不是老是,是由于不一样的线程栈是不对其余线程设防的因此,若是个线程以某种方式获得个指向其余线程栈的指慧:那么它就能够读写这个栈的任何部分。
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
共享变量:
咱们说一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。
编写代码以下:
#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); }
临界区使用规则:
OS以一个地位高于进程的管理者的角度来解决公有资源的使用问题,信号量就是OS提供的管理公有资源的有效手段。
信号量的定义:
type semaphore=record count: integer; queue: list of process end; var s:semaphore;
对信号量的两个原子操做:
进程进入临界区以前,首先执行wait(s)原语,若s.count小于0,则进程调用阻塞原语,将本身阻塞,并插入到s.queue队列排队;
一旦其它某个进程执行了signal(s)原语中的s.count+1操做后,发现s.count ≤0,即阻塞队列中还有被阻塞进程,则调用唤醒原语,把s.queue中第一个进程修改成就绪状态,送就绪队列,准备执行临界区代码。
wait(s) s.count :=s.count-1; if s.count<0 then begin 进程阻塞; 进程进入s.queue队列; end;
signal(s) s.count :=s.count+1; if s.count ≤0 then begin 唤醒队首进程; 将进程从s.queue阻塞队列中移出; end;
经典进程互斥与同步问题:
基于预线程化的并发服务器
在如图所示的并发服务器中,咱们为每个新客户端建立了一个新线程这种方法的缺点是咱们为每个新客户端建立一个新线程,致使不小的代价。一个基于预线程化的服务器试图经过使用如图所示的生产者-消费者模型来下降这种开销。服务器是由一个主线程和一组工做者线程构成的。主线程不断地接受来自客户端的链接请求,并将获得的链接描述符放在一个不限缓冲区中。每个工做者线程反复地从共享缓冲区中取出描述符,为客户端服务,而后等待下一个描述符。
编写代码以下:
/* * 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; }
到目前为止,在对并发的研究中,咱们都假设并发线程是在单处许多现代机器具备多核处理器。并发程序一般在这样的机器上运理器系统上执行的。然而,在多个核上并行地调度这些并发线程,而不是在单个核顺序地调度,在像繁忙的Web服务器、数据库服务器和大型科学计算代码这样的应用中利用这种并行性是相当重要的。
1.四种不安全函数
(1):不保护共享变量的函数。
(2):保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。rand函数是线程不安全的,由于档期调用的结果依赖于前次调用的中间结果。当调用srand为rand设置了一个终止后,咱们从一个但线程中反复地调用rand,可以预期获得一个可重复的随机数字序列。
(3):返回指向静态变量的指针的函数。某些函数,例如ctime和gethostbyname,将计算结果放在一个static变量中,而后返回一个指向这个变量的指针。若是咱们从并发线程中调用这些函数,那么将可能发生灾难,由于正在被一个线程使用的结果会被另外一个线程悄悄地覆盖了。
有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的变量的地址。这就消除了全部共享数据,可是它要求程序员可以修改函数的源代码。
若是线程不安全是难以修改或不可能修改的,那么另一种选择是使用加锁-拷贝技术。基本思想是将线程不安全函数与互斥锁联系起来,在每个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果拷贝到一个私有的存储器位置,而后对互斥锁解锁。为了尽量减小对调用者的修改,你应该定义一个线程安全的包装函数,它执行加锁-拷贝,而后经过调用这个包装函数来取代对线程不安全函数的调用。
(4):调用线程不安全函数的函数。若是函数f调用线程不安全函数g,那么f就是线程不安全的吗?不必定。若是g是第二类资源,即依赖于跨越屡次调用的状态,那么f也是线程不安全的,并且除了重写g觉得,没有办法。然而,若是g是第一类或第三类函数,那么只要你用一个互斥锁保护调用位置和任何获得的共享数据,f仍然多是线程安全的。
2.可重入函数。可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入安全函数一般比不可重入函数更有效,由于它们不须要任何同步原语。
3.竞争。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。为了消除竞争,一般咱们会动态地分配内存空间。
4.死锁。当一个流等待一个永远不会发生的事件时,就会发生死锁。