20155213 《信息安全系统设计基础》第十四周学习总结
本周重点学习了《深入理解计算机系统》的第12章——并发编程;这也是我的搭档5303重点学习的一章 |
内容
并发编程概述
一、多路复用的并发
二、基于线程的并发
三、共享变量
四、信号同步
并发编程概述
如果逻辑控制流在时间上是重叠的,那么它们就是并发的。应用级并发可以发生在:
- 访问慢速I/O设备。当一个应用正在等待来自慢速I/O设备(例如磁盘)的数据到达时,内核会运行其他进程,使CPU保持繁忙。这是通过交替执行I/O请求和其他有用的工作来使用并发。
- 与人交互。用户希望计算机有同时执行多个任务的能力。每次用户请求某种操作(如单击鼠标)时,一个独立的并发逻辑流被创建来执行这个操作。
- 通过推迟工作来降低延迟。
- 服务多个网络客户端。我们期望服务器每秒为成百上千的客户端提供服务,并发服务器为每个客户端创建一个单独的逻辑流。
- 在多核机器上进行并行计算。被划分成并发流的应用程序通常在多核机器上比在单处理器上运行得快,因为这些流会并行执行,而不是交错执行。
现代操作系统提供了三种构造并发程序的方法:
- 进程。每个逻辑流都是一个进程,由内核来调度和维护。
- 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,说明这个描述符不再这个向量中,在再次循环的时候,需要将这个描述符再次添加到这个描述符集合中。
并发事件驱动程序中echo服务器中逻辑流的状态机,如下图所示:
#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 \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 */
当然,这个函数只是展示了多路复用的思想,对于这个程序来说,一旦链接到某个客户端,就会连续会送输入行,直到客户端关闭这个连接中它的那一端。因此,如果你键入一个命令到标准输入,将不会得到相应,直到服务器和客户端之间结束。一个更好的方法是更细粒度的多路复用,服务器每次循环回送一个问本行。
二、基于线程的并发
这里主要从线程和进程的关系及区别上解释线程:
【概念上】
一、 进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位;
二 、线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),一个线程可以创建和撤销另一个线程;
【进程与线程区别与联系】
(1) 划分尺度:线程更小,所以多线程程序并发性更高;
(2) 资源分配:进程是资源分配的基本单位,同一进程内多个线程共享其资源;
(3) 地址空间:进程拥有独立的地址空间,同一进程内多个线程共享其资源;
(4) 处理器调度:线程是处理器调度的基本单位;
(5) 执行:每个线程都有一个程序运行的入口,顺序执行序列和程序的出口,但线程不能单独执行,必须组成进程,一个进程至少有一个主线程。简而言之,一个程序至少有一个进程,一个进程至少有一个线程.
【linux上的进程与线程】
一般来说,线程是windows上的概念,windows区分进程和线程。而在linux(老版本)上,统一叫进程,进程是完成某项任务所需资源的集合,同时也是linux基本的执行单元。
Linux线程是通过进程来实现。Linux kernel为进程创建提供一个clone()系统调用,clone的参数包括如 CLONE_VM, CLONE_FILES, CLONE_SIGHAND 等。通过clone()的参数,新创建的进程,也称为LWP(Lightweight process)与父进程共享内存空间,文件句柄,信号处理等,从而达到创建线程相同的目的。
Linux 2.6的线程库叫NPTL(Native POSIX Thread Library)。POSIX thread(pthread)是一个编程规范,通过此规范开发的多线程程序具有良好的跨平台特性。尽管是基于进程的实现,但新版的NPTL创建线程的效率非常高。一些测试显示,基于NPTL的内核创建10万个线程只需要2秒,而没有NPTL支持的内核则需要长达15分钟。
【程序实例】
#include
#include
#include
#include
void *thread_foo_func(void *);
void *thread_bar_func(void *);
int global = 4;
int main(){
int local = 8;
int foo, bar;
pthread_t fthread, bthread;
foo = pthread_create(&fthread, NULL, thread_foo_func, (void *)&local);
bar = pthread_create(&bthread, NULL, thread_bar_func, (void *)&local);
if (foo != 0 || bar != 0){
printf("thread creation failed.\n");
return -1;
}
foo = pthread_join(fthread, NULL);
bar = pthread_join(bthread, NULL);
if (foo != 0 || bar != 0){
printf("thread join failed.\n");
return -2;
}
char i;
scanf("%c", &i);
return 0;
}
void *thread_foo_func(void *arg){
int foo_local = 16;
printf("address of global %d: %x\n", global, &global);
printf("address of main local %d: %x\n", *(int *)arg, arg);
printf("address of foo local: %x\n", &foo_local);
char i;
scanf("%c", &i);
}
void *thread_bar_func(void *arg){
int bar_local = 32;
printf("address of global %d: %x\n", global, &global);
printf("address of main local %d: %x\n", *(int *)arg, arg);
printf("address of bar local: %x\n", &bar_local);
char i;
scanf("%c", &i);
}
【运行结果】
关键看看几个局部变量的内存的范围,main函数的局部变量地址是:4beec428,在main函数栈的范围内:7fff4bed8000-7fff4beed000
foo的局部变量在foo的栈里面,内存范围是:7ffc63200000-7ffc63a00000。
bar的局部变量在bar的栈里面,内存范围是:7ffc629ff000-7ffc631ff000。
三、共享变量
一个变量时共享的,当且仅当这个线程引用这个变量的某个实例。
/* $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输出一条个性化的信息,以及调用该线程例程的总次数。看看到底哪些是共享的,哪些是私用的,哪些是可以更改的。
四、信号同步
据上所述,共享变量非常方便,但是引入了同步错误的可能性。
所以可以引入,信号同步线程。
共享变量引入了同步错误。
进度图: 轨迹线示例: 临界区(不安全区):
信号量:是用信号量解决同步问题,信号量s是具有非负整数值的全局变量,有两种特殊的操作来处理(P和V):
P(s):如果s非零,那么P将s减1,并且立即返回。如果s为0,那么就挂起这个线程,直到s变为非零;
V(s):V操作将s加1。
使用信号量实现互斥:
利用信号量调度共享资源:在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典应用:
a)生产者——消费者问题
要求:必须保证对缓冲区的访问是互斥的;还需要调度对缓冲区的访问,即,如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个空的槽位为止,如果缓冲区是空的(即没有可取的项目),那么消费者必须等待直到有一个项目变为可用。
注释:5~13行,缓冲区初始化,主要是对缓冲区结构体进行相关操作;16~19行,释放缓冲区存储空间;22~29行,生产(有空槽的话,在空槽中插入内容);32~4行,消费(去除某个槽中的内容,使该槽为空)
b)读者——写者问题
修改对象的线程叫做写者;只读对象的线程叫做读者。写着必须拥有对对象的独占访问,而读者可以和无限多个其他读者共享对象。读者——写者问题基本分为两类:第一类,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者等待而等待;第二类,写者优先,要求一定能写者准备好可以写,它就会尽可能地完成它的写操作。同第一类问题不同,在一个写者后到达的读者必须等待,即使这个写者也是在等待。以下程序给出了第一类读者——写者问题的解答:
注释:信号量w控制对访问共享对象的临界区的访问。信号量mutex保护对共享变量readcnt的访问,readcnt统计当前临界区的读者数量。每当一个写者进入临界区,它就对互斥锁w加锁,每当它离开临界区时,对w解锁,这就保证了任意时刻临界区最多有一个写者;另一方面,只有第一个进入临界区的读者对w加锁,而只有最后一个离开临界区的读者对w解锁。
综合:基于预线程的并发服务器之前介绍的基于线程的并发服务器,需要为每个客户端新建一个新线程,导致不小的代价。一个基于预线程化的服务器通过使用如下图所示的生产者——消费者模型来降低这种开销。服务器是由一个主线程和一组工作组线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个有限缓冲区中。每一个工作组线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。