20165232 第八周学习总结

20165232 第八周学习总结

教材内容学习

第十一章 网络编程

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

11.2 网络

  • 对主机而言,网络只是一种I/O设备,是数据源和数据接收方。
  • 物理上而言,网络是一个按照地理远近组成的层次系统。
  • 使用一些电缆和叫做网桥的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网。
    image

11.3 全球IP因特网

  • 一个因特网应用程序的硬件和软件组织
    image
  • IP地址:一个IP地址就是一个32位无符号整数
  • 网络程序将IP地址放在如下地址结构中
struct in_addr {
    uint32_t s_addr;
}
  • 因特网域名
  1. 因特网定义了域名集合和IP地址集合之间的映射
  2. 一个域名和一个IP地址之间一一映射
  3. 某些情况下,多个域名可以映射为同一个IP地址
  4. 通常情况下,多个域名可以映射到同一组的多个IP地址

11.4 套接字接口

  • 套接字接口是一组函数,他们和Unix I/O函数结合起来用于创建网络应用。
    image

  • connect 函数用来建立和服务器的连接

#include <sys/socket.h>
int connect(int clientfd, const struct sockadde *addr,
socklen_t addrlen);
  • bind函数告诉内核将addr中的服务器套接字地址和套接字描述符sockfd联系起来。
#include <sys/socket.h>
int connect(int sockfd, const struct sockadde *addr,
socklen_t addrlen);
  • listen函数告诉内核,描述符是被服务器而不是客户端使用的
#include <sys/socket.h>
int listen(int sockfd, int backlog);
  • 服务器通过调用accept函数来等待客户端的连接请求
#include <sys/socket.h>
int connect(int listenfd, struct sockadde *addr,int *addrlen);

主机和服务的转换

  • getaddrinfo函数

此函数将主机名,主机地址,服务名,端口号的字符串表示转化成套接字地址结构。
image

十二章 并发编程

现在操作系统提供了三种基本的构造并发程序的方法:

  • 进程。每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显示的进程间通信机制。
  • I/O多路复用。应用程序在一个进程的上下文中显式地调度他们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符之后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。
  • 线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。可以把线程看作其他两种方式的混合体,像进程一样由内核进行调度,而像I/O多路复用流一样共享同一个虚拟地址空间。

12.1 基于进程的并发编程

构造并发程序最简单的方法就是用进程,使用像fork、exec、和waitpid的函数。例如,一个构造并发服务器的自然方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

在父、子进程之间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误。另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。

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

假如要求你编写一个echo服务器,它也能对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:1)网络客户端发送连接请求,2)用户在键盘上键入命令行。如果在accept中等待一个连接请求,就不能响应输入的命令。如果再read中等待一个用户输入,就不能响应任何连接请求。

一个解决办法就是I/O多路复用(I/O multiplexing)技术。基本思想是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序。

这个技术的优点有

  1. 比基于进程的设计给了程序员更多的对程序行为的控制
  2. 一个基于I/O多路复用的时间驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间,使得流之间共享数据变得很容易。最后,事件驱动设计常常比基于进程的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。

事件驱动设计的缺点就是编码复杂。随着并发粒度的减少,复杂性还会上升。另一个重大缺点是它们不能充分利用多核处理器。

12.3 基于线程的并发编程

线程(thread)就是运行在进程上下文中的逻辑流。线程由内核自动调度,并通过一个整数ID来识别线程。每个线程都有它自己的线程上下文,包括一个唯一的整数线程、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程中的线程共享该进程的整个虚拟地址空间。

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

和一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任何对等线程终止。每个对等线程都能读写相同的共享数据。

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

#include "csapp.h"
void *thread(void *vargp);

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread, NULL);
    pthread_join(tid, NULL);
    exit(0);
}

void *thread(void *vargp)   /*Thread Routine*/
{
    printf("hello, world!\n");
    return NULL;
}


创建线程

线程通过调用pthread_create函数来创建其他线程。

#include <pthread.h>
typedef void *(func)(void *);

//返回:若成功返回0,否则为非零
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
pthread_create函数创建一个新的线程,并带着一个输入变量arg,在线程的上下文中运行线程例程f。可以用attr参数来改变新创建线程的默认属性。当pthread_create返回时,参数tid包含新创建线程的ID。新线程可以通过调用pthread_self函数来获取它自己的线程ID。

#include <pthread.h>
//返回:返回调用者的线程ID
pthread_t pthread_self(void);

终止线程

一个线程是以以下方式之一来终止的:

当顶层的线程例程返回时,线程会隐式地终止。
通过调用pthread_exit函数,线程会显示地终止。如果主线程调用pthread_exit,它会等所有其他对等线程终止,然后再终止主线程和整个进程,返回值thread_return。
#include <pthread.h>
//返回:若成功返回0,否则为非零 
void pthread_exit(void *thread_return);
某个对等线程调用Unix的exit函数,该函数终止进程以及所有与该进程相关的线程。
另一个对等线程通过以当前线程ID座位参数调用pthread_cancle函数来终止当前线程。
#include <pthread.h>

//返回:若成功返回0,否则为非零
int pthread_cancle(pthread_t tid);

回收已终止线程的资源

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

#include <pthread.h>

//返回:若成功返回0,否则为非零
int pthread_join(pthread_t tid, void **thread_return);
pthread_join函数会阻塞,直到线程tid终止,将线程例程返回的(void*)指针赋值为thread_return指向的位置,然后回收已终止线程占用的所有存储器资源。

和Unix的wait函数不同,pthread_join函数只能等待一个指定的线程终止,没有办法让pthread_join等待任意一个线程终止。

分离线程

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

默认情况下, 线程被创建成可结合的。为了避免内存泄漏,每个可结合的线程都应该要么被其他线程显示地收回,要么通过调用pthread_detach函数被分离。

pthread_detach函数分离可结合线程tid。线程能够通过以pthread_self()为参数的pthread_detach调用来分离它们自己。

#include <pthread.h>

//返回:若成功返回0,否则为非零
int pthread_detach(pthread_t tid);

初始化线程

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

#include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

//总是返回0
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
once_control变量是一个全局或者静态变量,总是被初始化为PTHREAD_ONCE_INIT。当第一个用参数once_control调用pthread_once时,它调用init_routine,这是一个没有输入参数,也不返回什么的函数。接下来的以once_control为参数的pthread_once调用不做任何事情。当需要动态初始化多个线程共享的全局变量时,pthread_once函数是很有用的。

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

一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。

从实际操作角度来讲,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了一个存储器位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。

各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟存储器地址空间的栈区域中,并且通常是被相应的线程独立访问的。但不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任务部分。

共享变量

一个变量v是共享的,当且仅当它的一个实例被一个以上的线程引用,如全局变量和本地静态变量。

12.5 用信号量同步线程

共享变量是十分方便的,但它们也引入了同步错误(synchronization error)的可能性。

进度图

进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应于线程k的进度。图的原点对应于没有任何线程完成一条指令的初始状态。进度图将指令模型化为从一种状态到另一种状态的转换(transition)。合法的转换是向右或者向上的,两条指令不能在同一时刻完成,即对角转换是不允许的。程序绝不会反向运行,所有向下或向右的转换也是不合法的。

对于线程i,操作共享变量n内容的指令构成了一个(关于共享变量n的)临界区(critical section),这个临界区不应该和其他线程的临界区交替执行。换句话说,我们想要每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问。通常这种现象称为互斥

在进度图中,两个临界区的交集形成的状态空间区域称为不安全区。为了保证程序正确执行,我们必须以某种方式同步线程,使他们总有一条安全轨迹线。

信号量

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

P(s):如果s是非零的,那么P将s减1,并且立即返回。如果s为零,而一个V操作会重启这个线程。在重启后,P将s减1,并将控制返回给调用者。
V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。
P的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,将会将s减1,不能有中断。V中的加1操作也不是不可分割的,也就是加载、加1和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重新启动的顺序。

P和V的定义确保了以一个正在运行的程序绝不可能进入这样一个状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性。

Posix标准定义了很多操作信号量的函数。

#include <semaphore.h>

int sem_init(sem_t *sem, 0, unsigned int value);
int sem_wait(sem_t *sem);   /*P(s)*/
int sem_post(sem_t *sem);   /*V(s)*/ 
                            //返回:若成功则为0,若出错则为-1
复制代码
sem_init将信号量sem初始化为value。每个信号量在使用前必须初始化。针对我们的目的,中间的参数总是0。程序分别通过调用sem_wait和sem_post函数来执行P和V操作。为了简化,我们使用下面这些等价的P和V的包装函数:

#include "csapp.h"

void P(sem_t *s);   /*Warpper function for sem_wait*/
void V(sem_t *s);   /*Warpper function for sem_post*/

使用信号量来实现互斥

信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量与一个信号量s(初始值为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或者1。以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作成为对互斥锁加锁,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没解锁的线程称为占用这个互斥锁。

P和V操作的结果创建了一组状态,叫做禁止区,其中s<0。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区中的任何部分。

读者-写者问题

读者-写者问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象,例如一个主存中的数据结构,或者一个磁盘上的数据库。

int readcnt;
 sem_t mutex, w;

void reader(void)
{
     while(1){
       P(&mutex);
        readcnt++;
        if(readcnt == 1) /*First in*/
             P(&w);
       V(&mutex);

         /*Critical section*/
         /*Reading happens*/
 
         P(&mutex);
         readcnt--;
         if(readcnt == 0);  /*Last out*/
             V(&w);
       P(&mutex);
     }
 }
 
 void writer(void)
 {
     while(1){
         P(&w);
         /*Critical section*/
         /*Writing happens*/
         V(&w);
      }
 }
posted @ 2018-11-22 23:21  何彦达  阅读(202)  评论(0编辑  收藏  举报