程序间交互和通信

程序间交互和通信

系统级I/O

文件

  1. 普通文件:文本文件和二进制文件
  2. 目录包含一组链接的文件,每个链接都将文件映射到另一个文件。每个目录至少包含两个条目:’.’表示目录本身的链接和’..’表示父目录的链接
  3. 套接字是用来与另一个进程的进行跨网通信的文件
  4. 其他文件类型还包括命名通道、符号链接以及字符和块设备

打开和关闭文件

  1. open函数打开文件或者创建一个新文件,open将filename转换为一个文件描述符,并且返回文件描述符
    fd=Open("foo.tst",O_RDONLY,0);以只读的方式打开一个已存在的文件
  2. flag参数可以是一个或多个位的掩码的或,为写提供一些额外的指示
  3. O_CREAT表示如果文件不存在,就创建它的一个截断的空文件;O_TRUNC表示如果文件已经存在,就截断它;O_APPEND表示每次写操作前,设置文件位置到文件结尾处
    fd=Open("foo.tst",O_WRONLY|O_APPEND,0)
  4. mode参数设置新文件的访问权限位
  5. 进程使用close关闭文件

读和写程序

  1. 程序通过read和write函数执行输入和输出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <unistd.h>
    ssize_t read(int fd,void *buf,size_t n);
    //read函数从描述符为fd的当前文件复制最多n个字节到内存位置buf
    //若成功则返回读的字节数,若EOF返回0,若失败返回-1

    ssize_t write(int fd,const void *buf,size_t n);
    //write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置
    //成功返回写的字节数,失败返回-1

    //read和write传送的字节比应用程序要求的少,不足值不代表错误
    //如果打开的文件应用于网络套接字,内存缓冲约束和较长的网络延迟会引起read和write返回不足值
  2. 通过调用lseek函数能够修改当前文件的位置

  3. 用RIO包读写(Robust I/O)
  • 无缓冲的输入输出函数,直接在内存和文件之间读写文件
  • 带缓冲的输入输出函数,缓存在应用级的缓冲区内
  1. 无缓冲的输入输出函数
    1
    2
    3
    4
    5
    6
    #include <"csapp.h">
    ssize_t rio_readn(int fd,void *usrbuf,size_t n);
    //从描述符为fd的当前文件复制最多n个字节到内存位置usrbuf
    ssize_t rio_writen(int fd,void *usrbuf,size_t n);
    //从内存位置usrbuf复制至多n个字节到描述符fd
    //只有遇到EOF时才会返回不足值,对同一个描述符可以任意交错调用rio_readn和rio_writen的代码

5.带缓冲的输入输出函数,调用包装函数,从内部缓冲区复制一个文本行,当缓冲区变空时,自动调用read填满

1
2
3
4
5
6
7
8
9
10
#include <"csapp.h">

void rio_readinitb(rio_t *rp,int fd);
ssize_t rio_readlineb(rio_t fd,void *usrbuf,size_t maxlen);
//每打开一个描述符,都会调用一次rio_readinitb函数,将描述符fd和地址rp处的读缓冲区联系起来
//rio_readinitb函数从文件rp中读出下一个文本行(包括结尾的换行符),将它复制到内存为止,usrbuf用NULL结束这个文本行
//rio_readinitb函数最多读maxlen-1个字节,余下的一个字节留给NULL,超过的字符被截断
ssize_t rio_readnb(rio_t fd,void *usrbuf,size_t n);
//rio_readnb函数从文件rp中最多读n个字节到内存位置usrbuf
//对同一个描述符,可以任意交叉调用rio_readinitb和rio_readnb

读取文件元数据

  1. 程序调用stat和fstat函数,检索到关于文件的信息(文件元数据)
    1
    2
    3
    4
    5
    6
    7
    #include <unistd.h>
    #include <sys/stat.h>

    int stat(const char *filename,struct stat *buf);
    //以文件名作为输入,填写stat数据结构中的各个成员,Web服务器需要stat数据结构中的st_mode和st_size成员
    int fstat(int fd,struct stat *buf);
    //以文件描述符作为输入

读取目录内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//程序用readdir系列函数来读取目录的内容
#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char *name);
//以路径名为参数,返回指向目录流的指针(目录项列表),出错则返回NULL

#include <dirent.h>

struct dirent *readdir(DIR *dirp);
//每次对readdir的调用的返回都指向流dirp中下一个目录项的指针,如果失败返回NULL并设置errno

//每个目录的结构
struct dirent{
ino_t d_ino;//文件位置
char d_name[256];//文件名
};

#include <dirent.h>
int closedir(DIR *dirp);
//关闭流并释放所有资源

共享文件

  1. 内核用三个相关的数据结构表示打开的文件:
  • 描述符表:表项由进程打开的文件描述符索引的,,每个打开的描述符表项指向文件表中的一个表项
  • 文件表:所有进程共享表示打开文件集合的文件表,每个文件表项组成包括当前文件位置、引用计数、以及指向v-node表中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数,内核不会删除这个文件表表项,直到引用计数为0
  • v-node表:所有进程共享,每个表项包含stat结构的大多数信息
  1. 多个描述符通过不同的文件表表项引用同一个文件,如以同一个filename调用open两次

    文件共享

    子进程继承父进程的打开文件

I/O重定向

  1. Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来
    ls >foo.txt #将标准输出重定向到磁盘文件
  2. I/O重定向通过dup2工作
    1
    2
    3
    #include <unistd.h>
    int dup2(int oldfd,int newfd);
    //复制描述符表表项oldfd到描述符表表项newfd,覆盖newfd表项以前的内容,如果newfd已经打开了,则在oldfd复制之前关闭newfd

标准I/O

  1. 库libc提供了fopen、fclose、fread、fwrite、fgets、fputs、scanf和printf
  2. 将打开的文件模型化为一个流,类型为FILE的刘是对文件描述符和流缓冲区的抽象

选择合适的I/O

  1. 首选标准I/O
  2. 不要使用scanf和rio_readlineb来读二进制文件
  3. 对网络套接字的I/O使用RIO函数
  4. 标准I/O是全双工的,程序能够在同一个流上执行输入和输出
  5. 限制一:如果中间没有插入fflush、fseek、fsetpos和rewind的调用,输入函数不能跟在输出函数之后,fflush清空与流相关的缓冲区,后三个函数使用lseek函数重置当前文件位置。在每个输入操作前刷新缓冲区
  6. 限制二:如果中间没有插入fseek、fsetpos和rewind函数的调用,输出函数不能跟随在输入函数之后。对网络套接字使用lseek是非法的,对同一个打开的套接字打开两个流,一个用来写一个用来读
  7. 在网络套接字上不适用标准I/O,要使用RIO函数

网络编程

  1. 客户端和服务器运行在不同的主机上,通过计算机网络的硬件和软件资源来通信
  2. 网络是一种I/O设备,是数据源也是数据接收方,网络的物理接口,从网络上接收到的数据从适配器经过I/O和内存总线复制到内存,通过DMA传送,类似的数据也能从内存复制到网络
  3. 物理网络最底层LAN,最流行的局域网技术是以太网,集线器不加分辨的将从一个口收到的东西复制到其他每个口,每台主机都能看到每个位,每个以太网适配器都要有唯一的48位地址,存储在适配器的非易失性存储器上
  4. 主机可以发送帧到这个网段内的其他任何主机,每个帧都包含固定数量的头部,用来标识此帧的源和目的,此后紧跟的是数据位的有效载荷,每个主机适配器都可以看到这个帧,但是只有目的主机可以实际读取他
  5. 使用电缆和网桥可以将多个以太网段连接成较大的局域网,称为桥接局域网,网桥有选择地将帧从一个端口复制到另一个端口
  6. 多个不兼容的局域网可以通过路由器的特殊计算连接起来,组成互联网络
  7. 路由器连接到高速点到点电话连接,称为WAN广域网
  8. 用运行在每台主机和路由器上的软件协议,消除不同网络之间的差异,协议的两种基本能力:
  • 命名机制,用一致的主机地址格式消除不兼容的局域网技术带来的差异,每台主机至少被分配一个互联网络地址,唯一的标识主机
  • 传送机制,互联网协议定义一种将数据位捆扎成(不连续的片)的统一方式消除差异。
  1. 包由包头和有效载荷组成,包头包含源主机和目的主机的地址,有效载荷包括从源主机发出的数据位
  2. 主机A传送数据到主机B的8个步骤如图所示

IP地址

  1. IP地址就是32位无符号整数(IPv4)
  2. IP地址结构

    1
    2
    3
    struct in_addr{
    uint32_t s_addr;
    };
  3. TCP/IP为任意整数数据项定义了统一的网络字节顺序(大端字节顺序),Unix使用函数在网络和主机字节顺序间实时转换

  4. IP地址通常是点分十进制表示的,128.2.194.242就是地址0x8002c2f2。Linux系统中可以用一下命令来确定主机的点分十进制地址hostname -i

并发编程

  1. 应用程序并发在以下几种情况是很有用的:访问慢速I/O设备、与人交互、通过推迟工作降低延迟、服务多个网络客户端、在多核机器上进行并行运算
  2. 三种基本构造并发程序的方法:进程、I/O多路复用、线程

基于进程的并发编程

  1. 在父进程接受客户端的连接请求,然后创建一个新的子进程来为每个新客户端提供服务
  2. 服务器监听一个监听描述符上的连接请求,然后假设服务器接受了客户端1的连接请求,并返回一个已连接描述符。在接受连接请求后,服务器派生子进程
  3. 这个子进程获得服务器描述表的完整副本,子进程关闭它的副本中的监听描述符3,父进程关闭已连接描述符4的副本,否则会引起内存泄露
  4. 包括SIGCHLD处理程序,回收僵死程序,当SIGCHLD处理程序执行时,SIGCHLD信号的阻塞的
  5. 父进程必须关闭它们各自的connfd副本
  6. 套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止
  7. 进程并发的劣势:
  • 父进程和子进程共享状态信息,进程有一个清晰的模型,共享文件表,但是不共享用户地址空间
  • 独立的地址空间使得进程共享状态信息变得困难,必须使用显式的IPC(进程间通信)机制进行共享信息
  • 进程控制和IPC开销很高,运行很慢,套接字接口是IPC的一种重要形式,允许不同的主机上的进程交换任意的字节流,包括管道、先进先出、系统V共享内存以及系统V信号量

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

  1. 服务器响应两个相互独立的I/O时间:1)网络客户端发起连接请求;2)用户在键盘键入命令行
  2. 使用select函数,要求挂起进程,只有在一个或多个I/O时间发生后,才将控制返回给程序

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //等待一组描述符准备好读
    #include <sys/select.h>

    int select(int n, fd_set *fdset, NULL, NULL, NULL);

    FD_ZERO(fd_set *fdset);//创建于一个空的读集合
    FD_CLR(int fd, fd_set *fdset);//
    FD_SET(int fd, fd_set *fdset);//定义读集合
    FD_ISSET(int fd, fd_set *fdset);//确定哪个描述符准备好读了
    //fd_set集合是描述符集合
    /*允许对描述符做三件事,分配它们,将此种类型的变量赋值给另一个变量,用FD_ZERO、FD_CLR、FD_SET和FD_ISSET宏来修改它们*/
    /*select函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。从该描述符读取一个字节不会阻塞*/
    /*函数修改参数fdset,返回值指明准备好集合的技术,每次调用select函数都会更新读集合*/
  3. 一旦连接到某个客户端,就会连续回送输入行,直到客户端关闭连接中的一端

I/O复用的优势和劣势

  1. 优点:有更多的对程序行为的控制;运行在单一进程的上下文中,每个逻辑控制流都能访问该进程的全部地址空间;共享进程容易,可以用熟悉的调试工具调试并发服务器
  2. 事件驱动的缺点:编码复杂,代码比基于进程的服务器多3被;并发粒度减小,复杂性上升;在故意只发送部分文本然后停止的恶意客户端攻击前很脆弱;不能充分利用多核处理器
  3. 粒度是每个逻辑流每个事件片执行的指令数量

基于线程的并发编程

  1. 线程是运行在进程上下文中的逻辑流,线程由内核自动调度
  2. 每个线程都有自己的线程上下文,包括线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码
  3. 所有运行在一个进程中的线程共享该进程的整个虚拟空间

线程执行模型

  1. 每个进程开始都是单一线程,这个线程称为主线程
  2. 主线程创建一个对等线程,从这个时间点开始,两个进程并发地运行
  3. 主线程执行一个满系统调用,或者被系统的间隔计时器中断,控制会通过上下文切换传递到对等线程
  4. 线程的上下文比进程的上下文小很多,线程的上下文切换比进程的上下文切换快的多
  5. 主线程总是第一个运行的线程
  6. 和一个进程相关的进程组成一个对等线程池,独立于线程产生的线程
  7. 一个线程可以kill它的任何对等线程,或者等待它的任意对等线程终止
  8. 每个对等线程都能读写相同的共享数据

Posix线程

  1. Posix线程是C程序中处理线程的一个标准接口,定义了约60个函数,允许程序创建、杀死和回收线程,与对等线程安全的共享数据,通知对等线程系统状态的变化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include "csapp.h"

    void *thread(void *vargp);
    /*线程的本地代码被封装在一个线程例程中,每个线程例程都以一个通用指针作为输入,并返回一个通用指针*/
    /*如果要传递多个参数给线程,将多个参数放在一个结构中,传递一个指向该结构的指针*/

    int main()
    {
    pthread_t tid; /*tid存放对等线程的ID*/
    Pthread_create(&tid, NULL, thread. NULL);
    /*创建一个新的对等线程,主线程和新创建的对等线程同时运行,并且tid包含新线程的ID*/
    Pthread_join(tid, NULL); /*主线程等待对等线程终止*/
    exit(0); /*终止运行在这个进程中的所有线程*/
    }

    void *thread(void *vargp) /*定义对等线程*/
    {
    printf("hello,world!\n");
    return NULL;
    }

创建线程

  1. 线程通过调用pthread_create来创建其他线程
    1
    2
    3
    4
    5
    6
    7
    8
    #include <pthread.h>
    typedef void *(func)(void *);

    int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
    /*输入变量arg,在新线程上下文中运行线程例程f*,能用attr参数来改变新创建线程的默认属性,tid包含线程ID/

    #include <pthread.h>
    pthread_t pthread_self(void); /*新线程调用pthread_self函数来获得自己的线程ID*/

终止线程

  1. 顶层的线程例程返回时,线程隐性终止
  2. 调用pthread_exit函数,线程会显式的终止,等待所有其他对等线程终止,再终止主线程和整个进程,返回值为thread_return
  3. 某个线程调用Linux的exit函数,函数终止该进程以及与该进程相关的线程
  4. 另一个对等线程通过当前进程ID作为参数调用pthread_cancel函数来终止当前线程
1
2
3
4
#include <pthread.h>
void pthread_exit(void *thread_return);

void pthread_cancel(pthread_t tid);
1
2
3
4
5
6
//回收已终止线程的资源
#include <pthread.h>
int pthread_join(pthread_t tid, void **thread_return);
/*函数会阻塞,直到线程tid终止,将线程例程返回的通用void*指针赋值为thread_return指向的位置*/
/*然后回收已终止线程占用的所有内存资源*/
/*pthread_join只能等待一个指定的线程终止*/

分离线程

  1. 在任何一个时间点上线程是可结合的或者是分离的
  2. 可结合的线程能够被其他线程收回或杀死,在被其他线程回收前,它的内存资源不释放
  3. 分离的线程不能被其他线程回收或者杀死,内存资源在终止时被释放
  4. 默认情况下,线程被创建成可结合的
    1
    2
    3
    4
    5
    #include <pthread.h>
    int pthread_detach(pthread_t tid);
    /*分离可结合线程tid*/
    /*web浏览器的连接请求都创建一个新的对等线程,不需要显式的等待每个线程的终止*/
    /*每个对等线程都应该在开始处理请求之前分离它自身,就能在终止后回收它的内存资源*/

初始化线程

  1. pthread_once函数允许初始化线程例程相关的状态
    1
    2
    3
    4
    5
    #include <pthread.h>
    pthread_once once_control=PITHREAD_ONCE_INIT;
    int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
    /*once_control是一个全局变量或者静态变量,总是被初始化为PITHREAD_ONCE_INIT*/
    /*第一次用参数once_control调用pthread_once时,调用init_routine*/

基于线程的并发服务器

  1. 主进程不断等待连接请求,创建一个对等线程处理该请求
  2. 将已连接描述符传递给对等线程,传递这个描述符的指针,但是这种对等线程的赋值语句和主线程的语句之间引入了竞争,将accept返回的每个已连接描述符分配到自己的动态分配内存块
  3. 为了避免内存泄露,必须分离每个线程,使得他终止时的每个内存资源能被收回
  4. 线性内存模型
  • 一组并发线程运行在进程的上下文中,每个线程都有自己的上下文
  • 每个线程和其他线程共享进程上下文的剩余部分,包括用户虚拟内存空间,是由只读文本、读写数据、堆以及所有共享库代码和数据区域组成的。线程也共享相同的打开文件合集
  1. 将变量映射到内存
  • 全局变量:虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用
  • 本地自动变量:是定义在函数内部,但是没有static属性的变量,在运行时,每个线程的栈都包含它自己所有自动变量的实例
  • 本地静态变量:是定义在函数内部并且有static属性的变量,虚拟内存的读/写区域只包含每个静态变量的一个实例
    6.共享变量,变量的一个实例被多个线程引用

用信号量同步线程

  1. 共享变量引入同步错误
  2. 两个对等线程在单处理器上同步运行,导致循环中的步骤错乱

进度图

  1. 进度图将指令模型化为一种状态到另一种状态的转换,转换被表示为有向线段
  2. 两条指令不能同时完成,不允许对角转换,不允许反向执行,不能向下或者向左
  3. 线程的临界区不允许和其他进程的临界区交替执行
  4. 保证每个线程在执行临界区时,有对共享内存变量的互斥访问
  5. 两个交界区形成的状态空间区域成为不安全区
  6. 绕开不安全区域的轨迹线叫做安全轨迹线

信号量

  1. 信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理P和V
  2. P(s):如果s非0,s-1,如果s为零,挂起这个线程,直到s为非0
  3. V(s):s+1,如果有操作阻塞在P等待s变成非0,V操作会重启线程中的一个,然后P将s-1
  4. P和V保证正确初始化的信号量不会有一个负值,这个属性称为信号量不变性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //POSIX标准定义了许多操作信号量的函数
    #include <semaphore.h>

    int sem_init(sem_t *sem, 0, unsigned int value); /*将信号量sem初始化为value*/
    int sem_wait(sem_t *s); //执行P操作
    int sem_post(sem_t *s); //执行V操作

    //等价的P和V的包装函数
    #include "csapp.h"

    void P(sem_t *s);
    void V(sem_t *s);
  5. 使用信号量来实现互斥

  • 用P(s)和V(s)将临界区包围起来,使用这种二元信号量来保护共享变量的信号量
  • 提供互斥为目的的二元信号量通常也称信号锁,P是互斥锁加锁,V是互斥锁解锁
  • 一个被用作一组可用资源计数的信号量被称为是计数信号量
  • 使用s<0的状态建立一个禁止区,保证了临界区的互斥访问

利用信号量来调度共享资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typrdef struct{
int *buf;
int n;
int front; /*索引值,记录数组的第一项*/
int rear; /*索引值,记录数组的最后一项*/
sem_t mutex; /*提供互斥的缓冲区访问*/
sem_t slots; /*记录空槽位数量*/
sem_t items; /*记录可用项目的数量*/
}sbuf_t;

/*sbuf_init函数为缓冲区分配内存,初始化所有变量*/
void sbuf_init(sbuf_t *sp, int n)
{
sp->buf=Calloc(n,sizeof(int));
sp->n;
sp->front=sp->rear=0;
Sem_init(&sp->mutex,0,1);
Sem_init(&sp->slots,0,n);
Sem_init(&sp->items,0,0);
}
  1. 读者-写者问题读者优先,如果不是使用权限赋给写,读操作不需要等待;写优先,在写者后面到达的读者必须等待

使用线程提高并行性

  1. 将序列分配成t个不想交的区域,然后给t个不同的线程分配区域
  2. 为了避免多核之间全局变量同步和PV操作造成的大延迟,在每个线程中使用私有变量/局部变量计算局部和,这个私有变量不和其他线程共享,不需要互斥锁来保护更新,也不需要每次循环都同步全局变量
  3. 并行程序加速比:Sp=T1/Tp
  4. 相关测量量-效率:Ep=Sp/p=T1/pTp,具有高效率的程序比低效率的程序在有用的工作上花费更多的时间
  5. 弱扩展:在增加处理器数量的同时,增加问题的规模

线程安全

  1. 线程安全被多个并发线程反复强调,一直产生安全的结果,否则这个函数就是线程不安全
  • 第1类:不保护共享变量的函数
  • 第2类:保持跨越多个调用的状态的函数:单线程中使用另一个函数设置种子,多线程中不再使用任何static数据,重写函数编程可重入函数
  • 第3类:指向静态变量的指针的函数:重写函数或者使用加锁-复制技术,定义线程安全的包装函数,通过调用包装函数来避免线程不安全函数的调用
  • 调用线程不安全的函数:第2类只能重写函数编程可重入函数,第1、3类同第3种使用带有互斥锁的包装函数
  1. 可重入函数,一个重要的线程安全的函数,在被多个线程共享时,不会引用任何共享数据,是线程安全函数的一部分
  • 没有同步操作,比不可重入函数更高效一点
  • 只有值传递的函数是显式的可重入函数
  • 值传递和引用传递的函数是隐式可重入函数

竞争

  1. 程序的正确性依赖于一个线程要在另一个线程到达y点之前到达x点,会引发竞争
  2. 为示例程序中的每个整数ID分配一个独立的块,并传递给线程例程必须释放这些块以避免内存泄露

死锁

  1. 一组线程被阻塞了
  2. 两个禁止区重叠部分的左下方,会导致死锁
  3. 程序员使用P和V操作顺序不当,所有阻塞的线程都在等待一个不会发生的V
  4. 死锁是不可预测的
  5. 使用二元信号量来实现互斥时,可以用互斥锁加锁顺序原则来避免死锁。给定所有互斥操作一个全序,如果每个线程都以一种顺序获得互斥锁并以相反的顺序释放,这种操作是无思索的
WhitneyLu wechat
Contact me by scanning my public WeChat QR code
0%