信息安全系统设计基础第十三周学习总结

第十一章 网络编程

11.1 客户端—服务器编程模型

每个网络应用都是基于客户端-服务器模型的。客户端-服务器模型中的基本操作是事务(transaction)(这里的事务与数据库中的事务有区别,没有数据库事务的特性,如原子性,这时原事务仅仅是客户端和服务器之间执行的一系列步骤)。认识到客户端和服务器是进程,而不是在本上下文中常被称为的机器或者主机。

11.2 网络

  1. 一个以太网段,包括电缆和集线器;每根电缆都有相同的最大位带宽;集线器不加分辩地将一个端口上收到的每个位复制到其他所有的端口上。因此,每台主机都能看到每个位。
    使用电缆和网桥,多个以太网段可以连接成较大的局域网,称为桥接以太网。这些电缆的带宽可以是不同的。
    多个不兼容的局域网可以通过叫做路由器的特殊计算机连接起来,组成一个internet互联网络。

  2. 数据传输
    8个步骤:

11.3 全球IP因特网

1.IP地址

  1. IP地址是32位无符号整数,通过点分十进制法来表示。网络程序将其放在IP结构中:

     struct in_addr{
         unsigned int s_addr;
     }
    
  2. 因为因特网主机可以有不同的主机字节顺序,TCP/IP为任意整数数据项定义了一致的网络字节顺序,大端字节顺序。

  3. 在Linux系统中,用HOSTNAME命令确定本机点分十进制地址:
    Hostname –i
    128.2.194.242

2.因特网域名

(1)域名是一船用句点分隔的单词(字母、数字、破折号)
第一层常见域名:com、edu、gov、org和net。
下一层是二级域名,如cmu.edu。这些域名由ICANN各个授权代理按照先到先服务的基础分配。一旦得到了二级域名,就可以在这个子域中创建任何新的域名。

在最简单的情况下,一个域名和一个IP地址之间一一映射。
在某些请款下,多个域名可以映射为同一个IP地址。
在最通常的情况下,多个域名可以映射到多个IP地址。

3.因特网的连接

套接字(socket)是连接的端点(end-point)。每个套接字都有相应的套接字地址,由一个因特网地址和一个16位的整数端口组成的,用“地址:端口”来表示。
一个连接是由它两端的套接字地址惟一确定的。这对套接字地址叫做套接字对:(cliaddr:cliport, servaddr:servport)

4.套接字接口

套接字接口(socket interface)是一组用来结合unit I/O函数创建网络应用的函数。

(1)套接字地址结构

从unit内核的角度来看,套接字就是通信的端点;从unix程序的角度来看,套接字就是一个有相应描述符的打开文件。

(2)socket函数

用socjet函数创建套接字描述符。

 #include <sys/types.h>
 #include <sys/socket.h>

 int socket (int domain,int type, int protocol)
(3)connect()函数

客户端通过调用来建立和服务器的连接,或是将socket和connect函数包装成open_clientfd的辅助函数。

(4)bind()函数

用于将一个本地地址与一个套接字绑定在一起。

(5)listen()函数

在服务器端程序中,当socket与某一端口绑定后,需要监听该端口,及时处理到达该端口上的服务请求。

 int listen(int sockfd, int backlog);
(6)accept()函数

当某个客户端试图与服务器监听的端口连接时,该连接请求将排队等待服务器用accept接收它并为其建立一个连接。

(7)EOF是由内核检测到的一个条件,超出文件末端。

(8)简单的echo服务器一次只能处理一个客户端,称为迭代服务器。

11.5 Web服务器

Web服务器以两种不同的方式向客户端提供内容:

(1)静态内容(static content):从服务器磁盘取得文件并把它返回客户端来服务。

(2)动态内容(dynamic content):运行一个可执行文件,并把它的输出返回给客户端。在服务器上一个子进程的上下文中运行一个程序并将它的输出返回给客户端来服务。

  1. 在URL中,“?”用来分隔文件名和参数,多个参数用“&”来分隔。参数中不允许空格,用“%20”来表示。

  2. 可执行性文件都放在这这些目录中。
    (1)如,最后一个“/”不是unix的根目录,而表示被请求内容类型的主目录。
    (2)最小的URL后辍是“/”字符,所有的服务器将其扩展为某个默认的主面。

  3. HTTP事务

    (1)HTTP请求

    执行的格式是:
    如:GET / HTTP/1.1

(2)HTTP响应

执行格式:<version> <status code> <status message>
如:HTTP/1.0 200 OK

(3)通过CGI(common gateway interface),来解决客户端与服务器交互问题,如客户端如何将参数传给服务器,服务器如何将这些参数传给它所创建的子进程?服务器如何将子进程生成内容所需要的其他信息传递给子进程?等。

第十二章 并发编程

  1. 如果逻辑控制流在时间上重叠,那么它们就是并发的。这种现象,称为并发(concurrency)。

  2. 为了允许服务器同时为大量客户端服务,比较好的方法是:创建并发服务器,为每个客户端创建各自独立的逻辑流。现代OS提供的常用构造并发的方法有:进程、I/O多路复用和线程。

    (1)每个逻辑流都是一个进程,由内核来调度维护。每个进程都有独立的虚拟地址空间,控制流通过IPC机制来进行通信。

    (2)线程:运行在单一进程上下文中的逻辑流,由内核进行调度,共享同一进程的虚拟地址空间。
    由于进程控制和IPC的开销较高,所以基于进程的设计比基于线程的设计慢。
    常见IPC有:管道,FIFO,共享存储器,信号。

12.1 基于进程的并发编程

一、基于进程的并发服务器

  • 通常服务器会运行很长时间,我们必须要包括一个SIGCHLD处理程序,来回收僵死子进程的资源。

  • 其次,父子进程必须关闭它们各自的connfd拷贝。父进程关闭他的连接描述符,以避免存储器泄露。

  • 最后,因为套接字的文件表表项中的引用计数,指导父子进程的connfd都关闭后,到客户端的连接才会终止。

进程共享文件表,但是不共享用户地址空间,父子进程有独立的地址空间。

优点:一个进程不可能不小心覆盖另一个进程的虚拟存储器。

缺点:使进程共享状态信息更加困难(必须用显式的IPC(进程间通信)机制);
比较慢,因为进程和IPC开销比较高。

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

基本思路:

Select函数使用,要求内核挂起进程。只有在一个或者多个I/O事件发生后,才将控制返回给应用程序。

1.基于 I/O 多路复用的并发事件驱动服务器

  1. I/O 多路复用可以用做并发事件驱动 (event-driven) 程序的基础,在事件驱动程序中,流是因为某种事件而前进的。

  2. 将逻辑流模型化为状态机。不严格地说,一个状态机 (state machine) 就是一组状态 (state)、输入事件(input event) 和转移他(transition),其中转移就是将状态和输入事件映射到状态。每个转移都将一个(输入状态,输入事件)对映射到一个输出状态。自循环(self-loop) 是同一输入和输出状态之间的转移。节 点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状态机从某种初始状态开始执行。每个输入事件都会引发一个从当前状态到下一状态的转移。

  3. 服务器使用I/O多路复用,借助 select 函数检测输入事件的发生。

  4. 服务器调用 select 函数来 检测两种不同类型的输人事件:

    (1)来自一个新客户端的连接请求到达

    (2) 一个己存在的客户 端的己连接描述符准备好可以读了。

    init_pool 函数初始化客户端池。

    clientfd 数组表示已连接描述符的集合, 其中整数 -1 表示一个可用的槽位。初始时,已连接描述符集合是空的,而且监听描述符是 select 读集合中唯一的描述符。

    add_clieht函数添加一个新的客户端到活动客户端池中。

    在 clientfd 数组中找到一个空槽位后,服务器将这个已连接描述符添加到数组中,并初始化相应的RIO读缓冲区,这样一来我们就能够对这个描述符调用rio_readlineb。将这个已连接描述符添加到 select 读集合,并更新该池的一些全局属性。

    maxfd 变量记录了 select 的最大文件描述符。 maxi 变量记录的 是到 clientfd数组的最大索引,这样

    check_clients 函数就无需搜索整个数组了。check_clients 函数回送来自每个准备好的已连接描述符的一个文本行。 如果成功地从描述符读取了一个文本行,那么我们就将该文本行回送到客户。

    select 函数检测到输入事件,而 add_client 函数创建 一个新的逻辑流(状态机)。

    check_clients 函数通过回送输入行来执行状态转移,而且当客 户端完成文本行发送时,它还要删除这个状态机。

2.I/O 多路复用技术的优劣

事件驱动设计

  1. 优点:

    它比基于进程的设计给了程序员更多的对程序行为的控制。一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因 此每个逻辑流都能访问该进程的全部地址空间。

  2. 缺点:

    编码复杂。我们的事件驱动的并发 echo 服务器需要的代码比基于进程的服务器多三倍。不幸的是,随着并发粒度的减小,复杂性还会上升。这里的粒度是指每个逻辑流每个时间片执行的指令数量。

12.3 基于线程的并发编程

线程由内核自动调度,每个线程都有它自己的线程上下文(thread context),包括一个惟一的整数线程ID(Thread ID,TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。每个线程和其他线程一起共享进程上下文的剩余部分,包括整个用户的虚拟地址空间,它是由只读文本(代码),读/写数据,堆以及所有的共享库代码和数据区域组成的,还有,线程也共享同样的打开文件的集合。

1.线程执行模型

每个进程开始生命周期时都是单一线程,这个线程称为主线程 (main thread)。在某一时刻,主线程创建一个对等线程 (peer thread),从这个时间点开始,两个线程就并发地运行。最后,因为主线程执行一个慢速系统调用。或者因为它被系统的间隔计时器中断, 控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。

线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等线程池(a pool of peers),独立于其他线程创建的线程。进程中第一个运行的线程称为主线程。对等(线程)池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止;进一步来说,每个对等线程都能读写相同的共享数据。

2.Posix线程

Posix 线程 (Pthreads) 是在 C 程序中处理线程的一个标准接口。Pthreads 定义了大约 60 个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。

线程的代码和本地数据被封装在一个线程例程(thread routine) 中。如果想传递多个参数给钱程例程,那么你应该将参数放 到一个结构中,并传递一个指向该结构的指针。想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。

3.创建线程

pthread_create 函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新创建线程的默认属性。

当 pthread_create 返回时,参数 tid包含新创建线程的ID。新线程可以通过调用 pthread_self 函数来获得它自己的线程 ID.

4.终止线程

终止方式(4个):

  1. 当顶层的线程例程返回时,线程会隐式地终止。

  2. 通过调用 pthread_exit 函数,线程会显式地终止。如果主线程调用 pthread_exit , 它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为 thread_return。

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

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

5.回收已终止线程的资源

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

pthread_join 函数会阻塞,直到线程 tid 终止,将线程例程返回的 (void*) 指针赋值为 thread_return 指向的位置,然后回收己终止线程占用的所有存储器资源。
pthread join 函数只能等待一个指定的线程终止。

6.分离线程

在任何一个时间点上,线程是可结合的(joinable),或者是分离的(detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(如栈)是不释放的。相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。
示例程序

7.初始化线程

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

8.一个基于线程的并发服务器

调用 pthread_ create 时,如何将已连接描述符传递给对等线程。最明显的方法就是传递一个指向这个描述符的指针。
对等线程间接引用这个指针,并将它赋值给一个局部变量。

12.4 多线程程序中的共享变量

一、线程存储模型

线程由内核自动调度,每个线程都有它自己的线程上下文(thread context),包括一个惟一的整数线程ID(Thread ID,TID),栈,栈指针,程序计数器,通用目的寄存器和条件码。每个线程和其他线程一起共享进程上下文的剩余部分,包括整个用户的虚拟地址空间,它是由只读文本(代码),读/写数据,堆以及所有的共享库代码和数据区域组成的,还有,线程也共享同样的打开文件的集合。
寄存器从不共享,而虚拟存储器总是共享。

二、将变量映射到存储器

  1. 全局变量——虚拟存储器的读/写区域只包含在程序中声明的每个全局变量的一个实例。

  2. 本地自动变量——每个线程的栈都包含它自己的所有本地自动变量的实例。

  3. 本地静态变量——虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

三、共享变量

我们说变量v是共享的,当且仅当它的一个实例被一个以上的线程引用。

12.5 用信号量同步

当对同一共享变量,有多个线程进行更新时,由于每一次更新,对该变量来说,都有“加载到寄存器,更新之,存储写回到存储器”这个过程,多个线程操作时,便会产生错位,混乱的情况,有必要对共享变量作一保护,使这个更新操作具有原子性。

一、进度图

进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。图的原点对应于没有任何线程完成一条指令的初始状态。

二、信号量

信号量s是具有非页整数值的全局变量,只能由两种特殊的操作来处理,称为P,V操作。

P(s): 
   while (s <= 0); s--;
   V (s): s++;
  1. 基本思想是,将每个共享变量(或者相关共享变量集合)与一个信号量s(初始值1)联系起来,然后用P(s),V(s)操作将相应的临界区(一段代码)包围起来。以这种方法来保护共享变量的信号量叫做二进制信号量(binary semaphore),因为值总是1,0。

  2. 二进制信号量通常叫做互斥锁,在互斥锁上执行一个P操作叫做加锁,V操作叫做解锁;一个已经对一个互斥锁加锁而还没有解锁的线程被称为占用互斥锁。

三、用信号量来调度共享资源

这种情况下,一个线程用信号量来通知另一个线程,程序状态中的某个条件已经为真了。如生产者-消费者问题。

四、基于预线程化的并发服务器

常规的并发服务器中,我们为每一个客户端创建一个新线程,代价较大。一个基于预线程化的服务器通过使用“生产者-消费者模型”来试图降低这种开销。

服务器由一个主线程和一组worker线程组成的,主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个共享的缓冲区中。每一个worker线程反复从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。

示例代码

/* $begin echoservertpremain */
#include "csapp.h" 
#include "sbuf.h" 
#define NTHREADS  4 
#define SBUFSIZE  16 

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

sbuf_t sbuf; /* shared buffer of connected descriptors */   

int main(int argc, char **argv)  
{   
    int i, listenfd, connfd, 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]); 
    sbuf_init(&sbuf, SBUFSIZE); 
    listenfd = Open_listenfd(port); 

    for (i = 0; i < NTHREADS; i++)  /* Create worker threads */
        Pthread_create(&tid, NULL, thread, NULL); 

    while (1) {  
        connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen); 
        sbuf_insert(&sbuf, connfd); /* Insert connfd in buffer */
    } 
} 

void *thread(void *vargp)  
{   
    Pthread_detach(pthread_self());  
    while (1) {  
        int connfd = sbuf_remove(&sbuf); /* Remove connfd from buffer */
        echo_cnt(connfd);                /* Service client */
        Close(connfd); 
    } 
} 
/* $end echoservertpremain */

如上模型组成事件驱动服务器,事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化为状态机,带有主线程和worker线程的简单状态机。

12.6 使用线程提高并行性

最直接的方法是将序列划分成t个不相交的区域,然后给t个不同的线程每个分配一个区域。假设n是t的倍数,这样每个区域有N/T个元素。主线程创建T个对等地运行线程,每个对等线程k并行地运行在它自己的处理的核上,并计算Sk,SK是区域k中元素的和。一旦对等线程计算完毕,主线程通过把每个Sk都加起来,计算出最终的结果。

12.7 其他并发问题

一、线程安全

一个函数被称为线程安全(thread-safe)的,当且仅当多个线程反复地调用时,它会一下产生正确的结果。

定义四个(不相交的)线程不安全的函数类:

第一类:不保护共享变量的函数
       利用P,V操作解决这个问题。
第二类:保持跨越多个调用的状态的函数
       srand设置种子,调用rand生成随机数。多线程调用时就出问题了。我们可以重写之解决,使之不再使用任何静态数据,取而代之地依靠调用者在参数中传递状态信息。
第三类:返回指向静态变量的指针的函数
       某些函数(如gethostbyname)将结果放在静态结构中,并返回一个指向这个结构的指针。多线程并发可能引发灾难,因为正在被一个线程使用的结果会被另一个线程悄悄覆盖。
       
       两种方法处理:
           一是重写之。使得调用者传递存放结果的结构的地址,这就消除了共享数据。
           第二种方法是:使用称为lock-and-copy的技术。在每一个调用位置,对互斥锁加锁,调用线程不安全函数,动态地为结果分配存储器,copy函数返回结果到这个存储器位置,对互斥锁解锁。

第四类:调用线程不安全函数的函数
       f调用g。如果g是2)类函数,则f也是不安全的,只能得写。如果g是1)或3)类函数,则利用互斥锁保护调用位置和任何想得到的共享数据,f仍是线程安全的。如上例中。

二、可重入性

  1. 可重入函数(reenterant function)具有这样的属性:当它们被多个线程调用时,不会引用任何共享数据。

    可重入函数通常比不可重入函数高效一些,因为不需要同步操作。

  2. 显示可重入——如果所有的函数参数都是传值传递(没有指针),且所有的数据引用都是本地的自动栈变量(没有引用静态或全局变量),则函数是显式可重入的,无论如何调用,都没有问题。

  3. 隐式可重入——允许显式可重入函数中部分参数用指针传递,则隐式可重入的。在调用线程时小心传递指向非共享数据的指针,它才是可重入。如rand_r。
    可重入性同时是调用者和被调用者的属性。

三、C库中常用的线程不安全函数及unix线程安全版本

四、竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会发生竞争(race)。

五、死锁

信号量引入一个潜在的运行是错误-死锁。死锁是因为每个线程都在等待其他线程运行一个根本不可能发生的V操作。

避免死锁是很困难的。当使用二进制信号量来实现互斥时,可以用如下规则避免:【互斥锁加锁顺序规则】
如果用于程序中每对互斥锁(s,t),每个既包含s也包含t的线程都按照相同顺序同时对它们加锁,则程序是无死锁的。

posted on 2015-12-06 16:34  20135318刘浩晨  阅读(260)  评论(0编辑  收藏  举报