IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 网络协议 -> TCP IP网络编程笔记——尹圣雨 -> 正文阅读

[网络协议]TCP IP网络编程笔记——尹圣雨

问题:

  1. write函数 第二个参数,为什么转换成char*形式?

    自己理解:char大小为1,转换成char指针,表示从第一个字节开始,第三个参数表示传递大小——字节数。与地址指针对应

  2. 传输数据时,数据的格式是什么?

  3. 9.2中用SO_REUSEADDR后,Time_Wait状态时端口号被分配给其他套接字,但是最后的ACK消息没有正常传到主机B,此时B进行FIN消息重传,会不会传到其他位置(端口号已经分配给其他套接字)???(重传时是否还需要端口号??)

网络编程

第一章

大致函数作用

socket——》安装电话机;

bind——》分配电话号码;

listen——》连接电话线;

accept——》拿起话筒;

connect——》打电话。

**文件描述符:**系统分配给文件或套接字的整数。(一种标识
LINUX->描述符;windows ->句柄

0、1、2是分配给标准I/O的描述符;然后从3开始以由小到大的顺序编号。

linux文件操作

int open(const char *path, int flag);   
// path 文件名的字符串地址;  flag 文件打开模式信息 --> 成功时返回文件描述符,失败时返回-1。

#include <unistd.h>
int close(int fd);    // fd  需要关闭的文件或套接字的文件描述符。--> 成功时返回0,失败时返回-1。

ssize_t write(int fd, const void* buf, size_t nbytes);// fd 显示数据传输对象的文件描述符;buf 保存要传输数据的缓冲地址值。
// nbytes 要传输数据的字节数  --> 成功时返回写入的字节数,失败时返回-1。

ssize_t read(int fd, void *buf, size_t nbytes); // fd 显示数据接收对象的文件描述符;buf 要保存接收数据的缓冲地址值;
//nbytes 要接收数据的最大字节数  --> 成功时返回接收字节数(但遇到文件结尾则返回0), 失败时返回-1。 

//size_t表示unsigned int类型,ssize_t标识signed int类型
//* 数据类型_t 表示元数据类型,为操作系统定义的, 在 sys/types.h文件中一般由typedef声明定义

第二章 套接字类型与协议设置–socket函数

2.1 套接字协议及其数据传输特性

协议:计算机间对话必备通信规则。

创建套接字

#include <sys/socket.h>     
int socket(int domain, int type, int protocol);     
//--> 成功时返回文件描述符,失败时返回-1。
//domain 套接字中使用的协议族(Protocol Family)信息;
//type 套接字数据传输类型信息;
//protocol 计算机通信中使用的协议信息。
  • 协议族(Protocol Family)

image

? 套接字实际采用的最终协议信息是通过第三个参数传递的,在指定的协议族范围内通过第一个参数决定第三个参数。

  • 套接字类型(Type)

    指的是套接字的数据传输方式。 协议族中存在多种数据传输方式。

    • Type1:面向连接的套接字(SOCK_STREAM)

      面向连接的套接字除特殊情况外不会发生数据丢失。

      特征:传输过程中数据不会消失;

      ? 按序传输数据;

      ? 传输的数据不存在数据边界(Boundary)。

      ? **“传输数据的计算机通过3次调用write函数传递了100字节的数据,但接收数据的计算机仅通过1次read函数的调用就接收了全部100字节。” **

      ? 收发数据的套接字内部有缓冲(buffer),简言之就是字节的数组。通过套接字传输的数据将保到该数组。因此,收到数据并不意味着马上调用read函数。 只要不超过数组容量,则有可能在数据填充满缓冲后通过一次read函数调用读取全部,也有可能分成多次read函数调用进行读取。也就是说,在面向连接的套接字中,read函数和write函数的调用次数并无太大意义。
      “接字连接必须是一一对应”
      “可靠的、按需传递的、基于字节的面向连接的数据传输方式的套接字”

    • Type2:面向消息的套接字(SOCK_DGRAM)

      强调快速传输而非传输顺序。
      传输的数据可能丢失也可能损毁。
      传输的数据有数据边界。——》意味着接收数据的次数应和传输次数相同。
      限制每次传输的数据大小。
      特性:“不可靠、不按顺序传递的、以数据的高速传输为目的的套接字”

  • 协议的最终选择

    ? 前两个参数即可创建所需的套接字,所以大部分情况下可以向第三个参数传递0,除非遇到:“同一协议族中存在多个数据传输方式相同的协议”

    即数据传输方式相同,但协议不同。便需要第三个参数指定协议信息。

第三章 地址族与数据序列–bind函数

3.1 分配给套接字的IP地址与端口号

? IP 是为收发网络数据而分配给计算机的值
? 端口号是为区分程序中创建的套接字而分配给套接字的序号

  • 网络地址

image-20210712184638451

image-20210712185146191

? 先浏览IP地址的网络地址,把数据传到相应的网络;再浏览数据的**主机地址(主机ID)**并将数据传给目标计算机。

image-20210712184822806

  • 用于区分套接字的端口号

    ? 利用端口号才能将数据传输到相应目的应用程序

    ? 计算机中一般配有NIC(Network Interface Card,网络接口卡(网络适配器、网卡))数据传输设备。通过NIC向计算机内部传输数据时会用到IP。
    操作系统负责把传递到内部的数据适当分配套接字,这时就要利用端口号。也就是说,通过NIC接收的数据内有端口号,操作系统正是参考此端口号把数据传输给相应端口的套接字。

    ? 端口号就是在同一操作系统内为区分不同套接字而设置的,因此无法将1个端口号分配给不同的套接字。另外,端口号由16位构成,可分配端口号范围从0-65535。但0-1023是知名端口(Well-known PORT),一般分配给特定应用程序,所以应当分配范围之外的值。
    ? 端口不能重复,但TCP套接字和UDP套接字不会公用端口号,所以允许重复。

3.2 地址信息的表示

应用程序中使用的IP地址和端口号以结构体的形式给出定义。

  • 表示IPv4地址的结构体

    struct sockaddr_in      
    {
          sa_family_t       sin_family;   //地址族(Address Family)  
          uint16_t          sin_port;     //16位TCP/UDP端口号  
          struct in_addr    sin_addr;     //32位IP地址  
          char              sin_zero[8];  //不使用  
    }; 
    

    该结构体中提到的另一个结构体in_addr定义如下,它用来存放32位IP地址。

    struct in_addr
    {
      	in_addr_t           s_addr;       //32位IPv4地址
    };
    
    数据类型名称数据类型说明声明的头文件
    int 8_tsigned 8-bit intsys/types.h
    uint8_tunsigned 8-bit int (unsigned char)sys/types.h
    int16_tsigned 16-bit intsys/types.h
    uint16_tunsigned 16-bit int (unsigned short)sys/types.h
    int32_tsigned 32-bit intsys/types.h
    uint32_tunsigned 32-bit int (unsigned long)sys/types.h
    sa_family_t地址族(address family)sys/socket.h
    socklen_t长度(length of struct)sys/socket.h
    in_addr_tIP地址,声明为 uint32_tnetinet/in.h
    in_port_t端口号,声明为 uint16_tnetinet/in.h

    结构体 socket_in 的成元分析

    • 成员sin_family

      每种协议族适用的地址族均不同。

      地址族(Address Family)含义
      AF_INETIPV4用的地址族
      AF_INET6IPV6用的地址族
      AF_LOCAL本地通信中采用的 Unix 协议的地址族
    • 成员sin_port

      保存16位端口号。以网络字节序保存

    • 成员sin_addr

      保存32位IP地址信息,也是以网络字节序保存

    • 成员sin_zero

      ? 无特殊含义。只是结构体sockaddr_in的大小与sockeaddr结构体保持一致而插入的成员。必须填充为0,否则无法得到想要的结果。

      ? 实际上,bind函数的第二个参数期望得到sockaddr结构体变量地址值,包括地址族、端口号、IP地址等。从下列代码也可以看出,直接向sockaddr结构体填充这些信息会带来麻烦。

      struct sockaddr
      {
      	sa_family_t       sin_family;     // 地址族(Address Family)
      	char              sa_data[14];    // 地址信息
      };
      

      ? 此结构体成员sa_data保存的地址信息中需包含IP地址和端口号,剩余部分应填充0,这也是bind函数要求的。而这对于包含地址信息来讲非常麻烦,继而就有了新的结构体sockaddr_in。若按照之前的讲解填写sockaddr_in结构体,则将生成符合bind函数要求的字节流。最后转换为sockaddr型的结构体变量,再传递给bind函数即可。

      sockaddr_in与sockaddr二者长度一样,都是16个字节,即占用的内存大小是一致的,因此可以互相转化。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。

      一般先把sockaddr_in变量赋值后,强制类型转换后传入用sockaddr做参数的函数:sockaddr_in用于socket定义和赋值;sockaddr用于函数参数。

3.3 网络字节序与地址转换

不同的CPU中,4字节整数型值1在内存空间的保存方式是不同的。
00000000 00000000 00000000 00000001 大端序—》高位字节存放到低位地址。
有些CPU以这种顺序保存到内存,另外一些CPU则以倒序保存
00000001 00000000 00000000 00000000 小端序—》高位字节存放到高位地址

  • 字节序与网络字节序

    ? 代表CPU数据保存方式的主机字节序(Host Byte Order)在不同CPU中各不相同。目前主流的CPU以小端序方式保存数据。

    ? 在通过网络传输数据时约定统一方式,这种约定称为==网络字节序(Network Byte Order),非常简单——统一为大端序==。即,先把数据数组转换为大端序格式再进行网络传输。

    ? 因此,所有计算机接收数据时应识别该数据是网络字节序格式,小端序系统传输数据时应转化为大端序排列方式。

  • 字节序转换

//转换字节序的函数。
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);
//h代表主机(host)字节序,n代表网络(network)字节序,s指的是short,l指的是long;
//htons是 h、to、n、s的组合,也可以解释为“把short型数据从主机字节序转化为网络字节序”。
//ntohs,也可以解释为“把short型数据从网络字节序转化为主机字节序”。
//通常,以s作为后缀的函数中,s代表2个字节short,因此用于端口号转换;以l作为后缀的函数中,l代表4个字节,因此用于IP地址转换。

3.4 网络地址的初始化与分配

  • 将字符串信息转换为网络字节序的整数型

    ? sockaddr_in中保存地址信息的成员为32位整数型。因此,为了分配IP地址,需要将其表示为32位整数型数据。

    ? 201.211.214.36转换为4字节整形数据。IP地址是点分十进制表示法,而非整数型数据表示法。sockaddr中IP地址是字符串形式

    ? 有个函数会帮我们将字符串形式的IP地址转换成32位整形数据。此函数在转换类型的同时进行网络字节序转换。

    #include <arpa/inet.h>
    in_addr_t inet_addr(const char * string);//--> 成功时返回32位大端序(网络字节序)整数型值,失败时返回 INADDR_NONE。
    

    ? inet_addr函数不仅可以把IP地址转成32位整数型,而且还可以检测无效的IP地址。

    ? inet_aton函数与inet_addr函数在功能上完全相同,也将字符串形式IP地址转换为32位网络字节序整数并返回。只不过该函数利用了in_addr结构体,且其使用频率更高。

    #include <arpa/inet.h>
    int inet_aton(const char * string, struct in_addr * addr);  // string 含需转换的IP地址信息的字符串地址值;addr 将保存转换结果的in_addr结构体变量的地址值。
    //--> 成功时返回1(true),失败时返回0(false)
    
    //调用过程
    char * addr="127.232.124.79";
    struct sockaddr_in addr_inet;
    inet_aton(addr,&addr_inet.sin_addr);
    

    ? 与inet_aton函数正好相反的函数,此函数可以把网络字节序整数型IP地址转换成我们熟悉的字符串形式。(网络字节序整数型——》字符串)

    #include <arpa/inet.h>
    char * inet_ntoa(struct in_addr adr);//--> 成功时返回转换的字符串地址值,失败时返回-1。
    

    ? 返回值为字符串地址意味着字符串已保存到内存空间(内部申请了内存并保存了字符串)。若再次调用inrt_ntoa函数,则可能覆盖之前保存的字符串信息,所以若需要长期保存,则应将字符串复制到其他内存空间

  • 网络地址初始化

    struct sockaddr_in addr;
    char* ser_ip = "211.217.168.13";    // 声明 IP 地址字符串
    char* serv_port = "9190";           // 声明端口号字符串
    memset(&addr, 0, sizeof(addr));     // 结构体变量addr的所有成员初始化为 0
    addr.sin_family = AF_INET;          // 指定地址族
    addr.sin_addr.s_addr = inet_addr(serv_ip);    // 基于字符串的IP地址初始化
    addr.sin_port = htons(atoi(serv_port));       // 基于字符串的端口号初始化
    

    ? memset函数将每个字节初始化为同一值:第一个参数为结构体变量addr的地址值,即初始化对象为addr;第二个参数为0,因此初始化为0;最后一个参数中传入addr的长度,因此addr的所有字节均初始化为0。这么做是为了将sockaddr_in结构体的成员sin_zero初始化为0。

    ? atoi函数:(字符串——》整数型)

    总之,上述代码利用字符串格式的IP地址和端口号初始化了sockaddr_in结构体变量。

  • 客户端地址信息初始化

    ? 上述网络地址信息初始化过程主要针对服务器端。给套接字分配IP地址和端口号主要时为了下面这件事做准备:
    ? “请把进入IP 211.217.168.13、9190端口的数据传给我!”
    反观客户端中连接请求如下:
    “请连接到IP 211.217.168.13、9190端口!”

    ? 请求方法不同意味着调用的函数也不同。服务器端的准备工作通过bind函数完成,而客户端则通过connect函数完成。
    ? 因此,函数调用前需准备的地址值类型也不同。服务器端声明sockaddr_in结构体变量,将其初始化为为赋予服务器端IP和套接字的端口号,然后调用bind函数;而客户端则声明sockaddr_in结构体,并初始化为要与之连接的服务器端套接字的IP和端口号,然后调用connect函数。

  • INADDR_ANY

    每次创建服务器端套接字都要输入IP地址会有些繁琐,此时可如下初始化地址信息。

    struct sockaddr_in addr;
    char * serv_port = "9190";
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(atoi(serv_port));
    

    ? 与之前方式最大的区别在于,利用常数INADDR_ANY分配服务器端的IP地址。若采用这种方式,则可自动获取运行服务器端的计算机IP地址,不必亲自输入。 若同一计算机中已分配多个IP地址,只要端口号一致,就可以从不同IP地址接收数据。

    ? 实际IP地址个数==计算机中安装的NIC数量,即使是服务器套接字,也需要决定应接受哪个IP传来(哪个NIC)的数据。若只有1个NIC,则直接使用INADDR_ANY

  • 第1章的 hello_server.c、 hello_client.c运行过程

    ./hserver 9190  
    

    通过代码可知,向main函数传递9190为端口号。通过此端口号创建服务器端套接字并运行程序,但未传递IP地址,因为可以通过INADDR_ANY指定IP地址。

    ./hclient 127.0.0.1 9190
    

    127.0.0.1是回送地址(loopback address),指的是计算机自身IP地址。
    如果服务器端和客户端分别在2台计算计中运行,则可以输入服务器端IP地址。

  • 向套接字分配网络地址

    ? 前面讨论了sockaddr_in结构体的初始化方法,接下来就把初始化的地址信息分配给套接字。==bind函数==负责这项操作。

    #include<sys/socket.h>
    int bind(int sockfd, struct sockaddr_in * myaddr, socklen_t addrlen);  //--> 成功时返回0, 失败时返回-1。   // sockfd: 要分配地址信息(IP地址和端口号)的套接字文件描述符。 // myaddr: 存有地址信息的结构体变量地址值。
    // addrlen: 第二个结构体变量的长度。
    //如果此函数调用成功,则将第二个参数指定的地址信息分配给第一个参数中的相应套接字。
    

第四章 基于TCP的服务器端/客户端(1)

4.1 理解TCP和UDP

  • TCP/IP协议栈

    各层可能通过操作系统等软件实现,也可能通过类似NIC的硬件设备实现。

  • 链路层

    利用MAC地址在同网内传输,可主机——》路由器

  • IP层——网络层

    ? 利用IP地址在不同网之间传输数据。为了复杂的网络传输中传输数据,首先需要考虑路径的选择

    IP本身是面向消息的、不可靠的协议。

  • TCP/UDP层——传输层

    ? IP层解决数据传输中的路径选择问题,只需照此路径传输数据即可。TCP和UDP层以IP层提供的路径信息为基础完成实际的数据传输,故该层又称传输层 (Transport)。

    ? TCP可以保证可靠的数据传输,TCP协议确认后向不可靠的IP协议赋予可靠性。

  • 应用层

    ? **上述内容是套接字通信过程中自动处理的。选择数据传输路径、数据确认过程都被隐藏到套接字内部。**程序员编程时无需考虑这些过程,但并不意味着不用掌握这些知识。只有掌握了这些理论,才能编写出符合需求的网络程序。

    ? 向各位提供的工具就是套接字,大家只需利用套接字编写出程序可。编写软件过程中,需要根据程序特点决定服务器和客户端之间的数据传输规则 (规定),这便是应用层协议。

    ? 网络编程的大部分内容就是设计并实现应用层协议。

4.2 实现基于TCP的服务器端/客户端

  • 进入等待连接请求状态

    ? 通过调用listen函数进入等待连接请求状态。只有调用了listen函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能调用connect函数(若提前调用将发生错误)。

    #include <sys/socket.h>
    int listen(int sock, int backlog);
    //--> 成功时返回0, 失败时返回-1。    
    // sock: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数成为服务器端套接字(监听套接字)。
    // backlog: 连接请求等待队列(Queue)的长度,若为5,则队列长度为5,表示最多使5个连接请求进入队列。
    
  • 受理客户端连接请求

    ? 受理请求意味着进入可接受数据的状态。进入这种状态所需的部件–套接字! 下面这个函数将自动创建套接字,并连接到发起请求的客户端。

    #include <sys/socket.h>
    int accept(int sock, struct sockaddr * addr, socklen_t * addrlen);
    //--> 成功时返回创建的套接字文件描述符,失败时返回-1。
    // sock: 服务器套接字的文件描述符
    // addr: 保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量参数填充客户端地址信息。
    // addrlen: 第二个参数addr结构体的长度,但是存有长度的变量地址。函数调用完成后,该变量即被填入客户端地址长度。
    

    ? accept函数将产生用于数据I/O的套接字,并返回其文件描述符。需强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。

  • TCP客户端的默认函数调用顺序

    ? 服务器端调用 listen函数后 创建连接请求等待队列,之后客户端即可请求连接。

    #include <sys/socket.h>
    int connect(int sock, struct sockaddr* servaddr, socklen_t addrlen);
    //--> 成功时返回 0,失败时返回-1。
    // sock: 客户端套接字文件描述符。
    // servaddr: 保存目标服务器端地址信息的变量地址值。
    // addrlen: 以字节为单元传递已传递给第二个结构体参数servaddr的地址变量长度。
    

    ? 客户端调用 connect函数后,发生以下情况之一会返回(返回函数调用)

    1. 服务器端接收连接请求。

    2. 发生断网等异常情况而中断连接请求。

      ? 需要注意,所谓的 “接收连接” 并不意味着服务器端调用 accept函数,其实是服务器端把连接请求信息记录到等待队列。因此connect函数返回后并不立即进行数据交换

    Tips: 服务器端给套接字分配了IP和端口号。但客户端实现过程中并未出现套接字地址分配,而是创建套接字后立即调用了connect函数。

        1. **(何时)**调用connect函数时
           2. **(何地)**在操作系统中,更准确的说是内核中分配地址
           3.  **(如何)**IP用计算机(主机)的IP,端口随机。
    

    ? 客户端的IP地址和端口号在调用connect函数时自动分配,无需调用标记的 bind函数进行分配。

  • 基于TCP的服务器端/客户端函数调用关系

    image-20210713141146979

    ? 服务器端创建套接字后连续调用bind、listen函数进入等待状态,客户端通过调用connect函数发起连接请求。
    ? 需要注意的是,客户端智能等到服务器端调用listen函数后才能调connect函数。同时要清楚,客户端调用connnect函数前,服务器端才有可能率先调用accept函数。此时服务器端在调用accept函数时进入阻塞(blocking)状态,直到客户端调用connect函数为止。

4.3 实现迭代服务器端/客户端

? 回声(echo)服务器端/客户端,即服务器端将客户端传输的字符串数据原封不动地传回客户端,就像回声一样。

  • 实现迭代服务器端

    ? 调用accept函数后,紧接着调用I/O相关的read、write函数,然后调用close函数。这并非针对服务器端套接字,而是针对
    accept函数调用时创建的套接字。
    ? 调用close函数就意味着结束了针对某一客户端的服务。此时如果还想服务于其他客户端,就要重新调用accept函数。
    ? 同一个时刻确实只能服务于一个客户端。将来学完进程和线程之后,就可以编写同时服务多个客户端的服务器了

  • 迭代回声服务器端/客户端

    源码分析P83

  • 回声客户端存在的问题

    write(sock, message, strlen(message));
    str_len = read(sock, message, strlen(message));
    message[str_len] =0;
    printf("Message from server: %s", message);
    

    ? 以上代码有个错误假设: “每次调用read、write函数时都会以字符串为单元执行实际的I/O操作。”

    ? 客户端是基于TCP的——》TCP不存在数据边界(一次read读取多次write传递的数据)。因此多次调用write函数传递的字符串有可能一次性传递到服务器端。此时客户端有可能从服务器端收到多个字符串。

      “  字符串太长了,需要分2个数据包发送!”
      服务器端希望通过调用1次write函数传输数据,但如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。		另外在此过程中,**客户端有可能在尚未收到全部数据包时就调用read函数。**
    

第五章 基于TCP的服务器端/客户端(2)

5.1 回升客户端的完美实现

? 解决第4章上述的回声客户端存在的问题

  • 回声服务器端没有问题,只有回声客户端有问题?

    问题不在服务器端,而在客户端。

    ? 回声客户端传输的是字符串,而且是通过调用write函数一次性发送的。之后还调用一次read函数,期待着接收自己传输的字符串。这就是问题所在。

    ? 的确等段时间便可接收,但不确定需要等多久!理想的客户端在收到字符串数据时应立即读取并输出。

  • 回升客户端问题解决办法

    其实很容易解决,因为可以提前确定接收数据的大小。

    若之前传输了20字节长的字符串,则在接收时循环调用read函数读取20个字节即可

    ...
    while(recv_len < str_len){
        revc_cnt=read(aock,&message[recv_len],BUF_SIZE-1);
        if(recv_cnt==-1){
            error_handling("read() error!");
        }
        recv_len +=recv_cnt;
    }
    
    ...
    
  • 如果问题不在于回声客户端:定义应用层协议

    ? 回声客户端可以提前知道接受的数据长度,但是在大多数情况下这不太可能。此时需要的就是应用层协议的定义

    ? 之前回声服务端/客户端中定义了如下协议 :“收到Q就立即终止连接”。同样,收发数据过程中也需要定好规则(协议)以表示数据的边界,或提前告知收发数据的大小。

    ? 服务器/客户端实现过程中逐步定义的这些规则集合就是 应用层协议。

  • 计算器服务器端/客户端示例

    源码分析P97

5.2 TCP原理

? 为理解套接字选项(第9章)的基础

  • TCP 套接字中的 I/O缓冲

    ? write函数调用后并非立即传输数据,read函数调用后也并非马上接收数据。

    I/O缓冲特性:

    1. I/O缓冲在每个TCP套接字中单独存在

    2. I/O缓冲在创建套接字时自动生成

    3. 即使关闭套接字也会继续输出缓冲中遗留的数据(输出不停)

    4. 关闭套接字将丢失输入缓冲中的数据(输入停止)

      “不会发生超过输入缓冲大小的数据传输”,因为TCP会控制数据流滑动窗口协议

    因此TCP中不会因为缓冲溢出而丢失数据。

    ? write函数在数据移到输出缓冲中时便返回,而不是在完成向对方主机传输数据时。

  • TCP 内部工作原理1: 与对方套接字的连接

    TCP 套接字从创建到消失所经过程分为如下3步:

    ? 1.与对方套接字建立连接
    ? 2.与对方套接字进行数据交换
    ? 3.断开与对方套接字的连接

    • 建立连接

      三次握手

  • TCP 内部的工作原理2: 与对方主机的数据交换

    三次握手完成数据交换准备,收发数据过程如下:

    image-20210713202346152

    ACK号=SEQ号 + 传递的字节数 + 1

    数据传输过程出现错误,会超时重传

  • TCP 的内部工作原理3: 断开与套接字的连接

    ? 四次握手:先由套接字A向套接字B传递断开连接的消息,套接字B发出确认收到的消息,然后套接字B传递可以断开连接的消息,套接字A同样发出确认消息。

    image-20210713202704873

    FIN表示断开连接,双方各发送一次FIN消息后断开连接。

    B向A传递两次ACK 5001,第二次是因为发送ACK消息后未接收到数据而重传

第六章 基于UDP的服务器端/客户端

6.1 理解UDP

  • UDP套接字的特点

    UDP:不可靠、结构简单、性能高

    ? TCP 在不可靠的 IP层进行流控制,而 UDP就缺少这种流控制机制。流控制是区分 UDP和 TCP最重要的标志。

    每次交换的数据量越大,TCP的传输速率就越接近UDP的传输速率。

  • UDP内部工作原理

      UDP 最重要的作用就是根据端口号将传到主机的数据包交付给最终的 UDP套接字。**(主机内部)**
    
  • UDP的高效实用

    UDP 也具有一定的可靠性。

    ? 传递压缩文件——》TCP,因为压缩文件只要丢失一部分就很难解压。

    ? 多媒体数据(视频、音频等)——》UDP,因为丢失一部分没有太大问题。

    ? 提供实时服务——》UDP,因为速度非常重要,流控制就有些多余了。

    ? 如果收发的数据量小但需要频繁连接时,UDP比TCP高效。

6.2 实现基于UDP的服务器端/客户端

  • UDP中的服务器端和客户端没有连接

    无连接便不需要listen函数和accept函数。

    只有创建套接字的过程和数据交换过程

  • UDP服务器端和客户端均需1个套接字

    ? 收发信件时使用的邮筒可以比喻为UDP套接字。只要附近有一个邮筒,就可以通过它向任意地址寄出信件。

    1个UDP套接字就能和多台主机通信。

  • 基于UDP的数据I/O函数

    ? TCP套接字将保持与对方套接字的连接。
    ? 但UDP不会保持连接状态(UDP套接字只有简单的邮筒功能),因此每次传输数据都要添加目标地址信息

    //传输数据函数
    #include <sys/socket.h>
    ssize_t sendto(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen);
    //--> 成功时返回传输的字节数,失败时返回 -1
    //sock    用于传输数据的UDP套接字文件描述符
    //buff    保存待传输数据的缓冲地址值
    //nbytes  待传输的数据长度,以字节为单位
    //flags   可选项参数,若没有则传递0
    //to      存有目标地址信息的sockaddr结构体变量的地址值
    //addrlen 传递给参数to的地址结构体变量长度
    
    //接受UDP数据的函数
    #include <sys/socket.h>
    ssize_t recvfrom(int sock, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t * addrlen);  //UDP数据的发送端并不固定,因此该函数定义为可接收发送短信息的形式,也就是将同时返回UDP数据包中的发送端信息
    //--> 成功时返回接收的字节数,失败时返回 -1
    //sock    用于接收数据的UDP套接字文件描述符
    //buff    保存接收数据的缓冲地址值
    //nbytes  可接收的最大字节数,故无法超过参数buff所指的缓冲大小
    //flags   可选项参数,若没有则传入0
    //from    存有发送端地址信息的sockaddr结构体变量的地址值——》“原来没有,调用之后存放发送端信息。(自己理解)”
    //addrlen 保存参数from的结构体变量长度的变量地址值
    
  • 基于UDP的回声服务器端/客户端

    ? 源码分析P115

    ? recvfrom函数在调用同时获取数据传输端的地址,正是利用该地址将数据逆向重传

  • UDP 客户端套接字的地址分配

    ? TCP客户端调用connnect函数自动完成IP和端口号的分配。首次调用sendto函数时自动分配IP和端口号,分配地址一直保持到程序结束。

6.3 UDP的数据传输特性和调用connect函数

验证UDP数据传输中存在数据边界

  • 存在数据边界的UDP套接字

    TCP数据传输中国不存在数据边界,这表示“数据传输过程中调用I/O函数的次数不具有任何意义” 。

    UDP是具有数据边界的协议,**输入函数的调用次数和输出函数的调用次数完全一致。**才能保证接收全部已发送的数据

    **UDP数据报:**UDP存在数据边界,1个数据包即可成为1个完整数据,因此称为数据报。

  • 已连接(connected)UDP套接字与未连接(unconnected)UDP套接字

    ? TCP套接字中需注册待传数据的目标IP和端口号,而UDP中则无需注册。因此通过sendto函数传递数据的过程大致分为3个阶段:

    1. 向UDP套接字注册目标IP和端口号;(类似建立传输数据的通道)

    2. 传输数据;

    3. 删除UDP套接字中注册的目标地址信息 (切断通道)

      若同一发送端向同一接收端 发送多个数据包,可以1-2-2-2-3,建立已连接UDP套接字。

  • 创建已连接UDP套接字

    创建已连接UDP套接字的过程格外简单,只需针对UDP套接字调用connect函数。

    sock = socket(PF_INT, SOCK_DGRAM, 0);
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = ...;
    addr.sin_port = ...;
    connect(sock, (struct sockaddr*) &addr, sizeof(addr));
    

    ? UDP套接字调用connect函数并不意味着要与对方UDP套接字连接,这只是向UDP套接字注册目标IP和端口信息。
    之后就与TCP套接字一样,每次调用sendto函数时只需要传输数据。因此已经指定了收发对象,所以不仅可以使用sendto、recvfrom函数,还可以使用write、read函数进行通信。

第七章 优雅地断开套接字连接

7.1 基于TCP的半关闭

  • 单方面断开连接带来的问题

    ? Linux的close函数和windows的closesocket函数意味着完全断开连接。既无法传输数据,也不能接收数据。

      为了解决这类问题,“只关闭一部分数据交换中使用流”(Half—close)的方法应运而生。只关闭流的一半:可以传输数据但无法接收,或可以接收数据但无法传输。
    
  • 套接字和流(Stream)

    建立连接后的可交换数据的状态——》“流形成的状态”。

    每个主机有两个流:输入流、输出流。

    “优雅地断开”就是断开其中一个流

  • 针对优雅断开的 shutdown函数

    #include <sys/socket.h>
    int shutdown(int sock, int howto);
    //--> 成功时返回 0, 失败时返回 -1。
    //sock   需要断开的套接字文件描述符。
    //howto  传递断开方式信息。
    
    //第二个参数决定断开连接的方式
    //SHUT_RD: 断开输入流         套接字无法接收数据,即使输入缓冲收到数据也会抹去。且无法调用输入相关函数。
    //SHUT_WR: 断开输出流         中断输出流,输出缓冲中留有未传输的数据,则将传至目标主机。
    //SHUT_RDWR: 同时断开I/O流    同时关闭I/O流。相当于分两次调用shutdown,一次以SHUT_RD为参数,一次以SHUT_WR为参数。
    
  • 为何需要半关闭

    ? EOF表示文件传输结束。

    ? 调用close函数的同时关闭I/O流,这样也会向对方发送EOF。但此时无法再接收对方传输的数据。这时需要调用shutdown函数,只关闭服务器端 的输出流(半关闭)。这样既可以发送EOF,同时又保留了输入流,可以接收对方的数据。

  • 基于半关闭的文件传输程序

    源码分析P131

第八章 域名及网络地址

8.1 域名系统

DNS是对IP地址和域名进行互相转换的系统,其核心是DNS服务器。

  • 什么是域名

    ? 提供网络服务的服务器端也是通过IP地址区分的,但几乎不可能以非常难记的IP地址形式交换服务端地址信息。

    ? 因此,将容易记、易表述的域名分配并取代IP地址

  • DNS服务器

    域名是赋予服务器端的虚拟地址,而非实际地址。

    DNS服务器:将虚拟地址转化为实际地址

    ping 域名 得到IP地址

    nslookup 查看自己计算机中注册的默认DNS服务器地址

    Linux中 nslookup + server

    image-20210714145739770

    顶级DNS服务器——根DNS服务器

    DNS是层次化管理的一种分布式数据库系统

8.2 IP地址和域名之间的转换

  • 程序中有必要使用域名吗?

    域名更长久,IP地址可能会经常改变。所以程序中需要IP地址和域名之间的转换函数

  • 利用域名获取IP地址

    利用以下函数可以通过传递字符串格式的域名获取IP地址

    #include <netdb.h>
    struct hostent *gethostbyname(const char *hostname);
    //--> 成功时返回 hostent结构体地址, 失败时返回 NULL指针。
    
    struct hostent
    {
    	char *h_name;        //official name
        char **h_aliases;    //alias list
        int h_addrtype;      //host address type
        int h_length;        //address length
        char **h_addr_list;  //address list
    }
    //h_name: 该变量中存有官方域名(Official domain name)。官方域名各代表某一主页,但一些著名公司并未用官方域名注册。
    //h_aliases: 可以通过多个域名访问同一主页。同一IP可以绑定多个域名,因此,除官方域名外还可以指定其他域名。这些信息可以通过h_aliases获得。
    //h_addrtype: gethostbyname函数不仅支持IPv4,还支持IPv6。因此可以通过此变量获取保存在h_addr_list的IP地址的地址族信息。若是IPv4,此变量存有AF_INET。
    //h_length: 保存IP地址长度。若是IPv4地址,因为是4字节,则保存4;IPv6时,因为是16字节,故保存16。
    //h_addr_list: 这是最重要的成员。通过此变量以整数形式保存域名对应的IP地址。另外,用户较多的网站可能分配多个IP给同一域名,利用多个服务器进行负载均衡。此时同样可以通过此变量获取IP地址信息。
    

    image-20210714151940236

    h_addr_list字符串指针数组中的元素实际指向的事(实际保存的是)in_addr结构体变量地址而非字符串。所以需要类型转换inet_ntoa函数(网络字节序整数型——》字符串)

  • 利用IP地址获取域名

    #include <netdb.h>
    struct hostent * gethostbyaddr(const char* addr,socklen_t len ,int family)
    //成功返回hostent结构体变量地址值,失败返回NULL指针
    //addr	含有IP地址信息的in_addr结构体指针。为了同时传递IPv4地址之外的其他信息,该变量的类型声明为char指针
    //len	向第一个参数传递的地址信息的字节数,IPv4时为4,IPv6时为16
    //family	传递地址族信息,IPv4时为AF_INET,IPv6时为AF_INET6
    

    使用时注意类型的转换

第九章 套接字的多种可选项

套接字具有不同特性

9.1 套接字可选项和I/O缓冲大小

  • 套接字多种可选项

? 从表9-1可以看出,套接字可选项是分层的**。IPPROTO_IP层可选项是IP协议相关事项,IPPROTO_TCP层可选项是TCP协议相关的事项, SOL_SOCKET层是套接字相关的通用可选项。**

  • getsockopt & setsockopt
//读取套接字可选项
#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void* optval, socklen_t *optlen);
//--> 成功时返回 0,失败时返回 -1。
//sock     用于查看套接字文件描述符
//level    要查看的可选项的协议层
//optname  要查看的可选项名
//optval   保存查看结果的缓冲地址值
//optlen   向第四个参数optval传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数
  
//更改可选项时调用的函数

#include <sys/socket.h>
int setsockopt(int sock, int level, int optname, const void* optval, socklen_t optlen);
//--> 成功时返回 0,失败时返回 -1。
//sock     用于更改可选项的套接字文件描述符
//level    要更改的可选项协议层
//optname  要更改的可选项名
//optval   保存要更改的选项信息的缓冲地址值
//optlen   向第四个参数optval传递的可选项信息的字节数

源码分析P152

? 用于验证套接字类型的 SO_TYPE是典型的只读可选项:套接字类型只能在创建时决定,以后不能再更改。

  • SO_SNDBUF & SO_RCVBUF

    创建套接字将同时生成I/O缓冲。(自动创建)

    ? SO_RCVBUF是输入缓冲大小相关的可选项,SO_SNDBUF是输出缓冲大小的相关可选项。

    ? 用这2个可选项既可以读取当前I/O缓冲大小, 也可以进行更改。

    读取时用getsockopt函数,更改时用setsockopt函数

源码分析P153

注意:设置的缓冲大小,可能不会100%按照我们的请求设置,但也大致反映出了通过setsockopt函数设置的缓冲大小

9.2 SO_REUSEADDR

重点:SO_REUSEADDR及其相关的Time-wait状态

  • 发生地址分配错误(Binding Error)

    **地址分配错误:**若先断开服务器端会出现问题。如果用同一端口号重新运行服务器端,将输出 "bind() error"消息,并且无法再次运行。

    Time-wait状态:只有先断开连接的(先发送FIN消息的)主机才会经过 Time-wait状态。

    ? 因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait过程时, 相应端口是正在使用状态。因此,就像之前验证过得,bind函数调用过程中当然会发生错误。

    ? 先断开连接的套接字必然会经过 Time-wait过程。但无需考虑客户端 Time-wait状态。因为客户端套接字是任意指定的。

    存在Time-wait状态的原因:假设主机A向主机B传输ACK消息后立即消除套接字。但是这条ACK消息在传递中丢失,未能传输给B。这时主机B会认为自己发送的FIN消息未能到达主机A,继续重传,此时主机A已完全终止状态,因此主机B永远无法收到从主机A最后传来的ACK消息。相反,若A的套接字处在Time-wait状态,则会向主机B重传最后的ACK消息,主机B也能正常终止。

  • 地址再分配

    image-20210714203751613

9.3 TCP_NODELAY

  • Nagle算法

    为防止因数据包过多而发生网络过载,Nagle应用于TCP层

image-20210714205037432

? 右侧未使用Nagle算法,假设字符“N”到“e”依序传到输出缓冲。

? “只有收到前一数据的ACK消息时,Nagle算法才发送下一数据”

? TCP套接字默认使用Nagle算法交换数据,因此最大限度地进行缓冲,直到收到ACK。一般情况下,不使用Nagle算法可以提高传输速度。但如果无条件放弃使用Nagle算法,就会增加过多的网络流量,反而会影响传输。因此,未准确判断数据特性时不应禁用Nagle算法。

  • 禁用Nagle算法

image-20210714210053742

? “Nagle算法使用与否在网络流量上差别不大,使用Nagle算法的传输速度更慢”

禁用方法:只需将套接字可选项 TCP_NODELAY改为1(真)即可。

//禁用
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*) &optval, sizeof(opt_val));

//查看Nagle算法设置状态
int opt_val;
socklen_t opt_len;
opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*) &optval, &opt_len);

第十章 多进程服务器端

10.1 进程概念及应用

  • 并发服务器端的实现方法

    ? 网络程序中数据通信时间比CPU运算时间占比更大,因此,向多个客户端提供服务是一种有效利用CPU的方式。

    并发服务器端实现模型和方法:

    • 通过创建多个进程提供服务。(不太适合在Windows平台下(Windows不支持))
    • 多路复用服务器:通过捆绑并统一管理I/O对象提供服务。
    • 多线程服务器:通过生成与客户端等量的线程提供服务。
  • 理解进程(Process)**

    进程——“占用内存空间的正在运行的程序。”

  • **进程ID **

    ? 无论进程是如何创建的,所有进程都会从操作系统分配到ID。此ID称为 “进程ID”,其值为大于2的整数。1要分配给操作系统启动后的(用于协助操作系统) 首个进程,因此用户进程无法得到ID值为1。

    linux 中 ps当前运行的所有进程

  • 通过调用fork函数创建进程

    创建进程的方法很多,此处只介绍用于创建多线程服务器端的fork函数

    #include <unistd.h>
    pid_t fork(void);	//--> 成功时返回进程ID, 失败时返回 -1。
    

    ? fork函数将创建调用的进程副本(概念上略难)。也就是说,并非根据完全不同的程序创建进程,而是**复制正在运行的、调用fork函数的进程。**另外,两个进程都将执行fork函数调用后的语句(准确的说是在fork函数返回后)。但因为通过同一个进程、复制相同的内存空间,之后的程序流要根据fork函数的返回值加以区分。即利用fork函数的如下特点区分程序执行流程。

    • 父进程:fork函数返回子进程ID。——》原进程

    • 子进程:fork函数返回0。——》复制的进程

    ? 此处 “父进程”(Parent Process)指原进程,即调用fork函数的主体,而 “子进程”(Child Process)是通过父进程调用fork函数复制出的进程。
    ? fork函数调用后分成了两个完全不同的进程,只是二者共享同一代码而已。

10.2 进程和僵尸进程

? 进程的销毁

  • 僵尸(Zombie)进程

    ? 程完成工作后(执行完main函数中的程序后)应被销毁。但有时这些进程将变成僵尸进程,占用系统中的重要资源。是给系统带来负担的原因之一。

  • 产生僵尸进程的原因

    调用fork函数产生子进程的终止方式:

    • 传递参数并调用exit函数。

    • main函数中执行return语句并返回值。

      僵尸进程何时被销毁呢? “应该向创建子进程的父进程传递子进程的exit参数值或return语句的返回值。”

    ? 只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。

    ? ==子进程需要父进程去收回,而不是操作系统。==如果父进程终止,处于僵尸状态的子进程将同时销毁。

  • 销毁僵尸进程1: 利用 wait函数

    父进程主动请求获取子进程的返回值

    #include <sys/wait.h>
    pid_t wait(int * statloc); 	//--> 成功时返回终止的子进程ID, 失败时返回 -1。
    

    ? 调用此函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存在该函数的参数所指的内存空间中。但函数参数所指的单元中还包含其他信息,因此需要通过下列宏进行分离:

    • WIFEXITED 子进程正常终止时返回 “真”(true)。
    • WEXITSTATUS 返回子进程的返回值。
    if(WIFEXITED(status))     // 是正常终止的吗?
    {
        puts("Normal termination!");
        printf("Child pass num: %d", WEXITSTATUS(status));  //那么返回值是多少?
    }
    

    ? 调用wait函数时,如果没有已终止的子进程,那么程序将阻塞(Blocking)直到有子进程终止,因此需谨慎调用该函数。

  • 销毁僵尸进程2:使用 waitpid函数

    wait函数会引起程序阻塞,还可以考虑 waitpid函数。

    #include <sys/wait.h>
    pid_t waitpid(pid_t pid, int* statloc, int options);
    //--> 成功时返回终止的子进程ID(或0), 失败时返回 -1。     
    //pid       等待终止的目标进程的ID,若传递-1,则与 wait函数相同,可以等待任意子进程终止。
    //statloc   与wait函数的 statloc参数具有相同含义。
    //options   传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数。
    

    waitpid.c P174

10.3 信号处理

? ”子进程何时结束?父进程不能一直等待“

  • 向操作系统求助

    ? 子进程终止的识别主体是操作系统。

    ? 信号处理(Signal Handling)机制。此处的“信号”是在特定事件发生时由操作系统向进程发送的消息。 为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。

  • 信号和signal函数

    ? **”注册信号“过程:**即进程发现自己的子进程结束时,请求操作系统调用特定函数。(父进程告诉操作系统,在自己子进程结束时,操作系统应该去干什么)

    #include <signal.h>
    void (*signal(int signo, void (*func)(int)))(int);	//--> 为了在产生信号时调用,返回之前注册的函数指针。
    // (int)表示函数参数为int型;
    //第一个参数是指特殊情况信息;
    //第二个参数为特殊情况下将要调用的函数的地址值(指针)。
    //发生第一个参数代表的情况时,调用第二个参数所指的函数。
    //第一个参数信息:
    	//SIGALRM:  已到通过调用alarm函数注册的时间。
        //SIGINT:   输入 ctrl+C。
        //SIGCHLD:  子进程终止。
    
    signal(SIGCHLD, mychild);	//子进程终止则调用mychild函数。
    signal(SIGALRM, timeout);	//已到通过alarm函数注册的时间,请调用timeout函数
    signal(SIGINT, keycontol); 	//输入CTRL+C时调用keycontol函数
    //以上就是信号注册过程。注册好信号后,发生注册信号时(注册的情况发生时),操作系统将调用该信号对应的函数。
    
    #include<unistd.h>
    unsigned int alarm(unsigned int seconds);	//--> 返回 0或以秒为单位的距 SIGALRM信号发生所剩时间。
    //如果调用该函数的同时向它传递一个正整数型参数,相应时间后(以秒为单位)将产生 SIGALRM信号。若向该函数传递0,则之前对SIGALRM信号的预约将取消。如果通过该函数预约信号后未指定该信号对应的处理函数,则(通过调用signal函数)终止进程,不做任何处理。
    

    源码分析signal.c P178

    发生信号时将唤醒由于调用sleep函数而进入阻塞状态进程。

  • 利用 sigaction函数进行信号处理

    ? “signal函数在UNIX系列的不同操作系统中可能存在区别,但sigaction函数完全相同。”

    #include <signal.h>
    int sigaction(int signo, const struct sigaction* act, struct sigaction* oldact);//--> 成功时返回 0, 失败时返回 -1。
    //signo:  与signal函数相同,传递信号信息。
    //act:    对应与第一个参数的信号处理函数(信号处理器)信息。
    //oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递0。
    
    struct sigaction
    {
      void (*sa_handler)(int);	//信号处理函数的地址
      sigset_t sa_mask;			//0,用于指定信号相关的选项和特性
      int sa_flags;				0,同上
    }
    
    //sigemptyset(&act.sa_mask); //将sa_mask成员的所有位初始化为0
    
  • 利用信号处理技术消灭僵尸进程

    源码分析remove_zombie.c P181

10.4 基于多任务的并发服务器

  • 基于进程的并发服务器模型

    • 第一阶段:回声服务器端(父进程)通过调用accept函数受理连接请求。

    • 第二阶段:此时获取的套接字文件描述符创建 并传递给子进程。

    • 第三阶段:子进程利用传递来的文件描述符提供服务。

      ? 因为子进程会复制父进程拥有的所有资源,实际根本不会另外经过传递文件描述符的过程。

  • 实现并发服务器

    源码分析echo_mpserv.c P184

  • 通过fork函数复制文件描述符

    ? **套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。**所以子进程并不复制套接字,只是复制文件描述符。

    ? **1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。**所以在echo_mpserv.c中才会close两次。

10.5 分割TCP的I/O程序

客户端中分割I/O程序

  • 分割 I/O程序的优点

    原回声客户端:向服务器传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。

    原因:传输数据后要等待服务器端返回的数据,因为程序代码中重复调用了 read 和 write 函数。只能这么写的原因之一是,程序在 1 个进程中运行,现在可以创建多个进程,因此可以分割数据收发过程。

    ? 客户端的父进程负责接收数据,额外创建的子进程负责发送数据,分割后,不同进程分别负责输入输出,这样,无论客户端是否从服务器端接收完数据都可以进程传输。

    另一个好处:可以提高频繁交换数据的程序性能

  • 回声客户端的I/O程序分割

    源码分析echo_mpclient.c P190

第十一章 进程间通信

11.1 进程间通信的基本概念

? 进程间通信,意味着两个不同的进程中可以交换数据。

  • 对进程间通信的基本理解

    ? 进程具有完全独立的内存结构。就连通过fork函数创建的子进程也不会与父进程共享内存空间。因此,进程间通信只能通过其他特殊方法完成。

  • 通过管道实现进程间通信

    下图是基于**管道(PIPE)**的进程间通信的模型:

    ? 管道并非属于进程的资源,而是和套接字一样,属于操作系统(也就不是 fork 函数的复制对象)。所以,两个进程通过操作系统提供的内存空间进行通信。

    //创建管道
    #include <unistd.h>
    int pipe(int filedes[2]);
    /*
    成功时返回 0 ,失败时返回 -1
    filedes[0]: 通过管道接收数据时使用的文件描述符,即管道出口
    filedes[1]: 通过管道传输数据时使用的文件描述符,即管道入口
    */
    

    ? 父进程创建函数时将创建管道,同时获取对应于出入口的文件描述符,此时父进程可以读写同一管道。但父进程的目的是与子进程进行数据交换,因此需要将入口或出口中的 1 个文件描述符传递给子进程。调用fork函数后,子进程将同时拥有2个文件描述符。

    ? 可以看出,父子进程都可以访问管道的I/O路径,但子进程仅用输入路径,父进程仅用输出路径。

  • 通过管道进行进程间双向通信

? 源码pipe2.c P196

? 运行结果是正确的,但是如果注释掉第18行的代码,就会出现问题,导致一直等待下去。因为数据进入管道后变成了无主数据。**也就是通过 read 函数先读取数据的进程将得到数据,即使该进程将数据传到了管道。(自己读取自己写入的数据)**因为,注释第18行会产生问题。第19行,自己成将读回自己在第 17 行向管道发送的数据。结果父进程调用 read 函数后,无限期等待数据进入管道。

? 当一个管道不满足需求时,就需要创建两个管道,各自负责不同的数据流动。==源码pipe3.c P198==通过创建两个管道实现了功能,此时,不需要额外再使用 sleep 函数

11.2 运用进程间通信

  • 保存消息的回声服务器端

    下面对第 10 章的 echo_mpserv.c进行改进,添加一个功能:

    将回声客户端传输的字符串按序保存到文件中

    源码echo_storeserv.c P199

    共3个子进程,1个父进程;

    3个子进程:1个用来写入文件,另外两个用于与客户端交换数据;

第十二章 I/O复用

并发服务器的第二种实现方法——基于I/O复用的服务器端构建

12.1 基于 I/O 复用的服务器端

  • 多进程服务器端的缺点和解决方法

    ? 为了构建并发服务器,只要有客户端连接请求就会创建新进程。这的确是实际操作中采用的一种方案,但并非十全十美,因为创建进程要付出很大的代价。这需要大量的运算和内存空间,由于每个进程都具有独立的内存空间,所以相互间的数据交换也要采用相对复杂的方法。

    ? I/O 复用技术可以解决这个问题。

  • 理解复用

    复用:

    1. 在 1 个通信频道中传递多个数据(信号)的技术

    2.为了提高物理设备的效率,只用最少的物理要素传递最多数据时使用的技术

    **时分复用技术:**不同时进行;

    **频分复用技术:**可同时进行,但是存在“误差”

  • 复用技术在服务器端的应用

    ? 对比可以看出,引入复用技术之后,可以减少进程数。重要的是,无论连接多少客户端,提供服务的进程只有一个

12.2 理解select函数并实现服务器端

  • select 函数的功能和调用顺序

    ? 使用 select 函数时可以将多个文件描述符集中到一起统一监视,项目如下:

    • 是否存在套接字接收数据?

    • 无需阻塞传输数据的套接字有哪些?

    • 哪些套接字发生了异常?

监视项称为“事件(event)”;当发生监视项对应情况时,称「发生了事件」。

  • 设置文件描述符

    ? 监视文件描述符可以视为监视套接字。此时首先需要将要监视的文件描述符集中在一起。集中时也要按照监视项(接收、传输、异常)进行区分,即按照上述 3 种监视项分成 3 类。

    ? 利用 fd_set 数组变量执行此操作,如图所示,该数组是存有0和1的位数组

    ? 最左端的位表示文件描述符 0(所在位置)。如果该位设置为 1,则表示该文件描述符是监视对象。那么图中哪些文件描述符是监视对象呢?很明显,是描述符 1 和 3。

    //fd_set变量的操作是以位为单位进行的,这也意味着直接操作该变量会比较繁琐。
    //fd_set变量中注册过更改值的操作都由下列宏完成。  
    FD_ZERO(fd_set* fdset); 	//将fd_set变量的所有位初始化为0。
    FD_SET(int fd, fd_set* fdset);	// 在参数fdset指向的变量中注册文件描述符fd的信息。
    FD_CLR(int fd, fd_set* fdset);	//从参数fdset指向的变量中清除文件描述符fd的信息。
    FD_ISSET(int fd, fd_set* fdset);	//若参数fdset指向的变量中包含文件描述符fd信息,则返回“真”。
    
      int main(void)
      {
        fd_set set;
                           // fd0   fd1   fd2   fd3
        FD_ZERO(&set);     // 0     0     0     0  .....
    
                           // fd0   fd1   fd2   fd3
        FD_SET(1, &set);   //  0     1     0     0  .....
    
                           // fd0   fd1   fd2   fd3
        FD_SET(2, &set);   //  0     1     1     0  .....
    
                           // fd0   fd1   fd2   fd3
        FD_CLR(2, &set);   // 0     1     0     0  .....
      }
    
  • 设置检查(监视)范围及超时

    #include <sys/select.h>
    #include <sys/time.h>
    
    int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
    /*
    成功时返回大于 0 的值,失败时返回 -1
    maxfd: 监视对象文件描述符数量
    readset: 将所有关注「是否存在待读取数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
    writeset: 将所有关注「是否可传输无阻塞数据」的文件描述符注册到 fd_set 型变量,并传递其地址值。
    exceptset: 将所有关注「是否发生异常」的文件描述符注册到 fd_set 型变量,并传递其地址值。
    timeout: 调用 select 函数后,为防止陷入无限阻塞的状态,传递超时(time-out)信息。
    返回值: 发生错误时返回 -1,超时时返回0。
    因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数。
    */
    
    1. 文件描述符的监视(检查)范围是?
    2. 如何设定 select 函数的超时时间?

    第一,文件描述符的监视范围和 select 的第一个参数有关。实际上,select 函数要求通过第一个参数传递监视对象文件描述符的数量。因此,需要得到注册在 fd_set 变量中的文件描述符数。但每次新建文件描述符时,其值就会增加 1 ,故只需将最大的文件描述符值加 1 再传递给 select 函数即可。加 1 是因为文件描述符的值是从 0 开始的。

    第二,select 函数的超时时间与 select 函数的最后一个参数有关,其中 timeval 结构体定义如下:

    struct timeval
    {
        long tv_sec;	//seconds
        long tv_usec;	//microseconds
    };
    

    ? **本来 select 函数只有在监视文件描述符发生变化时才返回。**如果未发生变化,就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。此时,即使文件描述符未发生变化,**只要过了指定时间,也可以从函数中返回。不过这种情况下, select 函数返回 0 。**因此,可以通过返回值了解原因。如果不向设置超时,则传递 NULL 参数。

  • 调用 select 函数查看结果

    ? select函数的返回值,如果返回大于0的整数,说明相应数量的文件描述符发生变化。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ov4xorEQ-1631515893586)(https://s2.ax1x.com/2019/01/23/kA06dx.png)]

    ? select 函数调用完成候,向其传递的 fd_set 变量将发生变化。原来为 1 的所有位将变成 0,但是发生了变化的文件描述符除外。因此,可以认为值仍为 1 的位置上的文件描述符发生了变化

  • select 函数调用示例

    源码select.c P211

  • 实现I/O复用服务器端

    源码 echo_selectserv.c P213

    //客户端的连接请求同样通过传输数据完成。因此,服务器端套接字中有接受的数据,就意味着有新的连接请求

第十三章 多种I/O函数

13.1 send & recv函数

  • Linux 中的 send & recv

    #include <sys/socket.h>
    ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
    /*
    成功时返回发送的字节数,失败时返回 -1
    sockfd: 表示与数据传输对象的连接的套接字和文件描述符
    buf: 保存带传输数据的缓冲地址值
    nbytes: 待传输字节数
    flags: 传输数据时指定的可选项信息
    */
    
    #include <sys/socket.h>
    ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
    /*
    成功时返回接收的字节数(收到 EOF 返回 0),失败时返回 -1
    sockfd: 表示数据接受对象的连接的套接字文件描述符
    buf: 保存接受数据的缓冲地址值
    nbytes: 可接收的最大字节数
    flags: 接收数据时指定的可选项参数
    */
    

    ? send 和 recv 函数都是最后一个参数是收发数据的可选项,该选项可以用位或(bit OR)运算符(| 运算符)同时传递多个信息。

    可选项(Option)含义sendrecv
    MSG_OOB用于传输带外数据(Out-of-band data)OO
    MSG_PEEK验证输入缓冲中是否存在接受的数据XO
    MSG_DONTROUTE数据传输过程中不参照本地路由(Routing)表,在本地(Local)网络中寻找目的地OX
    MSG_DONTWAIT调用 I/O 函数时不阻塞,用于使用非阻塞(Non-blocking)I/OOO
    MSG_WAITALL防止函数返回,直到接收到全部请求的字节数XO
  • MSG_OOB:发送紧急消息

    MSG_OOB 可选项用于创建特殊发送方法和通道以发送紧急消息

    源码分析oob_send.c P223;oob_recv.c P224

    fcntl(recv_sock, F_SETOWN, getpid());
    //文件描述符 recv_sock 指向的套接字引发的 SIGURG 信号处理进程变为 getpid 函数返回值用作 ID 进程.
    //处理 SIGURG 信号时必须指定处理信号所用的进程,而 getpid 返回的是调用此函数的进程 ID
    

    通过 MSG_OOB 可选项传递数据时只返回 1 个字节,而且也不快。

    的确,通过 MSG_OOB 并不会加快传输速度,而通过信号处理函数 urg_handler 也只能读取一个字节。剩余数据只能通过未设置 MSG_OOB 可选项的普通输入函数读取。因为 TCP 不存在真正意义上的「带外数据」。实际上,MSG_OOB 中的 OOB 指的是 Out-of-band ,而「带外数据」的含义是:

    通过去完全不同的通信路径传输的数据

    即真正意义上的 Out-of-band 需要通过单独的通信路径高速传输数据,但是 TCP 不另外提供,只利用 TCP 的紧急模式(Urgent mode)进行传输。

  • 紧急模式工作原理

    ? MSG_OOB 的真正意义在于督促数据接收对象尽快处理数据。这是紧急模式的全部内容,而 TCP 「保持传输顺序」的传输特性依然成立。TCP 的紧急消息无法保证及时到达,但是可以要求急救

    MSG_OOB 可选项状态下的数据传输过程,如图:

    ? 如果缓冲最左端的位置视作偏移量 0 。字符 0 保存于偏移量 2 的位置。另外,字符 0 右侧偏移量为 3 的位置存有紧急指针(Urgent Pointer)。紧急指针指向紧急消息的下一个位置(偏移量加一),同时向对方主机传递一下信息:紧急指针指向的偏移量为 3 之前的部分就是紧急消息。

    ? **实际上只用了一个字节表示紧急消息。**这一点可以通过图中用于传输数据的 TCP 数据包(段)的结构看得更清楚,如图:

    TCP 数据包实际包含更多信息。TCP 头部包含如下两种信息:

    • URG=1:载有紧急消息的数据包

    • URG指针:紧急指针位于偏移量为 3 的位置。

      ? 指定 MSG_OOB 选项的数据包本身就是紧急数据包,并通过紧急指针表示紧急消息所在的位置。

    紧急消息的意义在于督促消息处理,而非紧急传输形式受限的信息。

  • 检查输入缓冲

    ? 同时设置 MSG_PEEK 选项和 MSG_DONTWAIT 选项,以验证输入缓冲是否存在接收的数据。**设置 MSG_PEEK 选项并调用 recv 函数时,即使读取了输入缓冲的数据也不会删除。**因此,该选项通常与 MSG_DONTWAIT 合作,用于调用以非阻塞方式验证待读数据存与否的函数。

    源码peek_recv.c peek_send.c P229

    ? 结果验证,仅发送了一次的数据被读取了 2 次,因为第一次调用 recv 函数时设置了 MSG_PEEK 可选项。

13.2 readv & writev 函数

  • 使用 readv & writev 函数

    readv & writev 函数的功能可概括如下:

    对数据进行整合传输及发送的函数。

    ? 也就是说,通过 writev 函数可以将分散保存在多个缓冲中的数据一并发送,通过 readv 函数可以由多个缓冲分别接收。因此,适用这 2 个函数可以减少 I/O 函数的调用次数。

    #include <sys/uio.h>
    ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
    /*
    成功时返回发送的字节数,失败时返回 -1
    filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符.
    iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息
    iovcnt: 向第二个参数传递  数组长度
    */
    
    struct iovec
    {
        void *iov_base; //缓冲地址
        size_t iov_len; //缓冲大小
    };
    

    ? writev 的第一个参数是1,是文件描述符,因此向控制台输出数据,ptr 是存有待发送数据信息的 iovec 数组指针。第三个参数为 2,因此,从 ptr 指向的地址开始,共浏览 2 个 iovec 结构体变量,发送这些指针指向的缓冲数据。

    #include <sys/uio.h>
    ssize_t readv(int filedes, const struct iovc *iov, int iovcnt);
    /*
    成功时返回接收的字节数,失败时返回 -1
    filedes: 表示数据传输对象的套接字文件描述符。但该函数并不仅限于套接字,因此,可以像 read 一样向向其传递文件或标准输出描述符.
    iov: iovec 结构体数组的地址值,结构体 iovec 中包含待发送数据的位置和大小信息
    iovcnt: 向第二个参数传递数组长度
    */
    
    //源码readv.c
    #include <stdio.h>
    #include <sys/uio.h>
    #define BUF_SIZE 100
    
    int main(int argc, char *argv[])
    {
        struct iovec vec[2];
        char buf1[BUF_SIZE] = {
            0,
        };
        char buf2[BUF_SIZE] = {
            0,
        };
        int str_len;
    
        vec[0].iov_base = buf1;
        vec[0].iov_len = 5;
        vec[1].iov_base = buf2;
        vec[1].iov_len = BUF_SIZE;
    
        str_len = readv(0, vec, 2);
        printf("Read bytes: %d \n", str_len);
        printf("First message: %s \n", buf1);
        printf("Second message: %s \n", buf2);
        return 0;
    }
    /*
    	26、27行:设置第一个数据的保存位置和大小。接收数据的大小已指定为5,因此无论buf1的大小是多少,最多只能保存5个字节
    	14、15行:vec[0]中注册的缓冲中保存5个字节,剩余数据将保存到vec[1]中注册的缓冲。结构体iovec的成员中ioe_len中应写入接收的最大字节数
    	17行:readv函数的第一参数是0,因此从标准输入接受数据。
    */
    
  • 合理使用 readv & writev 函数

    实际上,能使用该函数的所有情况都适用。

    ? 其意义在于减少数据包个数。假设为了提高效率在服务器端明确禁用了 Nagle 算法。其实 writev 函数在不采用 Nagle 算法(大文件)时更有价值,如图:

    ? 使用writev函数将所有数据一次性写入输出缓冲,则很有可能仅通过1个数据包传输数据。

第十四章 多播与广播

14.1 多播

? 多播(Multicast)方式的数据传输是基于 UDP 完成的。

? 区别在于,UDP 数据传输以单一目标进行,而多播数据同时传递到加入(注册)特定组的大量主机。换言之,采用多播方式时,可以同时向多个主机传递数据。

  • 多播的数据传输方式以及流量方面的优点

    多播的数据传输特点可整理如下:

    • 多播服务器端针对特定多播组,只发送 1 次数据。
    • 即使只发送 1 次数据,但该组内的所有客户端都会接收数据
    • 多播组数可以在 IP 地址范围内任意增加
    • 加入特定组即可接收发往该多播组的数据

    多播组是 D 类IP地址(224.0.0.0~239.255.255.255)(11100000 ~11101111)。

    多播是基于 UDP 完成的,也就是说,多播数据包的格式与 UDP 数据包相同。只是与一般的 UDP 数据包不同。向网络传递 1 个多播数据包时,路由器将复制该数据包并传递到多个主机。像这样,**多播需要借助路由器完成。**如图所示:

    ? 若通过 TCP 或 UDP 向 1000 个主机发送文件,则共需要传递 1000 次。但是此时如果用多播网络传输文件,则只需要发送一次。这时由 1000 台主机构成的网络中的路由器负责复制文件并传递到主机。就因为这种特性,多播主要用于「多媒体数据实时传输」

    ? 另外,理论上可以完成多播通信,但是不少路由器并不支持多播,或即便支持也因网络拥堵问题故意阻断多播。因此,为了在不支持多播的路由器中完成多播通信,也会使用隧道(Tunneling)技术

  • 路由(Routing)和 TTL(Time to Live,生存时间),以及加入组的办法

    ? 为了传递多播数据包,必须设置 TTL 。TTL 是 Time to Live的简写,是决定**「数据包传递距离」**的主要因素。**TTL 用整数表示,并且每经过一个路由器就减一。TTL 变为 0 时,该数据包就无法再被传递,只能销毁。**因此,TTL 的值设置过大将影响网络流量。当然,设置过小,也无法传递到目标。

    ? 接下来是 TTL 的设置方法。TTL 是可以通过第九章的套接字可选项完成的。与设置 TTL 相关的协议层为 IPPROTO_IP ,选项名为IP_MULTICAST_TTL。因此,可以用如下代码把 TTL 设置为 64

    int send_sock;
    int time_live = 64;
    ...
    send_sock=socket(PF_INET,SOCK_DGRAM,0);
    setsockopt(send_sock,IPPROTO_IP,IP_MULTICAST_TTL,(void*)&time_live,sizeof(time_live);
    ...
    

    ? 加入多播组也通过设置设置套接字可选项来完成。加入多播组相关的协议层为 IPPROTO_IP,选项名为 IP_ADD_MEMBERSHIP 。可通过如下代码加入多播组:

    int recv_sock;
    struct ip_mreq join_adr;
    ...
    recv_sock=socket(PF_INET,SOCK_DGRAM,0);
    ...
    join_adr.imr_multiaddr.s_addr="多播组地址信息";
    join_adr.imr_interface.s_addr="加入多播组的主机地址信息";
    setsockopt(recv_sock,IPPROTO_IP,IP_ADD_MEMBERSHIP,(void*)&join_adr,sizeof(time_live);
    ...
               
    //ip_mreq结构体
    struct ip_mreq
    {
        struct in_addr imr_multiaddr; //写入加入组的IP地址
        struct in_addr imr_interface; //加入该组的套接字所属主机的IP地址
    };
    
  • 实现多播 Sender 和 Receiver

    多播中用「发送者」(以下称为 Sender) 和「接收者」(以下称为 Receiver)替代服务器端和客户端。顾名思义,此处的 Sender 是多播数据的发送主体,Receiver 是需要多播组加入过程的数据接收主体。下面是示例,示例的运行场景如下:

    • Sender : 向 AAA 组广播(Broadcasting)文件中保存的新闻信息
    • Receiver : 接收传递到 AAA 组的新闻信息。

    源码news_sender.c news_receiver.c P243

14.2 广播

? 广播(Broadcast)在「一次性向多个主机发送数据」这一点上与多播类似,但传输数据的范围有区别。多播即使在跨越不同网络的情况下,只要加入多播组就能接受数据。相反,广播只能向同一网络中的主机传输数据。

  • 广播的理解和实现方法

    ? 广播是向同一网络中的所有主机传输数据的方法。与多播相同,广播也是通过 UDP 来完成的。根据传输数据时使用的IP地址形式,广播分为以下两种:

    • 直接广播(Directed Broadcast)
    • 本地广播(Local Broadcast)

    ? 二者在实现上的差别主要在于IP地址。**直接广播的IP地址中除了网络地址外,其余主机地址全部设置成 1。**例如,希望向网络地址 192.12.34 中的所有主机传输数据时,可以向 192.12.34.255 传输。换言之,可以采取直接广播的方式向特定区域内所有主机传输数据。

    ? 反之,本地广播中使用的IP地址限定为 255.255.255.255 。例如,192.32.24 网络中的主机向 255.255.255.255 传输数据时,数据将传输到 192.32.24 网络中所有主机。

    ? 数据通信中使用的IP地址是与 UDP 示例的唯一区别。默认生成的套接字会阻止广播,因此,只需通过如下代码更改默认设置。

    int send_sock;
    int bcast=1;	//对变量进行初始化以将SO_BROADCAST选项信息改为1。
    ...
    send_sock=socket(PF_INET,SOCK_DGRAM,0);
    ...
    setsockopt(send_sock,SOL_SOCKET,SO_BROADCAST,(void*)&bcast,sizeof(bcast));
    ...
    
  • 实现广播数据的 Sender 和 Receiver

    源码news_sender_brd.c news_recevier_brd.c P248

PART2 基于LINUX编程

第十五章 套接字和标准I/O

15.1 标准I/O函数的有点

  • 标准 I/O 函数的两个优点

    标准 I/O 函数的两个优点:

    • 标准 I/O 函数具有良好的移植性
    • 标准 I/O 函数可以利用缓冲提高性能

    ? 创建套接字时,操作系统会准备 I/O 缓冲。此缓冲在执行 TCP 协议时发挥着非常重要的作用。**此时若使用标准 I/O 函数,将得到额外的缓冲支持。**如下图:

    ? **套接字缓冲主要是为了实现TCP协议而设立的。**例如TCP传输中丢失数据时再次重传时,再次发送的数据存在套接字的输出缓冲中。

    ? **使用标准I/O函数缓冲的主要目的是为了提高性能。**从以下两点可以说明性能的提高:

    • 传输的数据量
    • 数据向输出缓冲移动的次数。

    ? 比较 1 个字节的数据发送 10 次(10个数据包)的情况和 10 个字节发送 1 次的情况。发送数据时,数据包中含有头信息。头信与数据大小无关,是按照一定的格式填入的。假设头信息占 40 个字节,需要传输的数据量也存在较大区别:

    • 1 个字节 10 次:40*10=400 字节
    • 10个字节 1 次:40*1=40 字节。
  • 标准 I/O 函数和系统函数之间的性能对比

    源码——系统函数的示例:syscpy.c P258

    源码——标准 I/O 函数复制文件:stdcpy.c

  • 标准 I/O 函数的几个缺点

    标准 I/O 函数存在以下几个缺点:

    • 不容易进行双向通信
    • 有时可能频繁调用 fflush 函数(刷新缓冲区)
    • 需要以 FILE 结构体指针的形式返回文件描述符。

15.2 使用标准I/O函数

? 创建套接字时返回文件描述符,为了使用标准I/O函数,只能将其转换为FILE结构体指针。

  • 利用 fdopen 函数转换为 FILE 结构体指针

    #include <stdio.h>
    FILE *fdopen(int fildes, const char *mode);
    /*
    成功时返回转换的 FILE 结构体指针,失败时返回 NULL
    fildes : 需要转换的文件描述符
    mode : 将要创建的 FILE 结构体指针的模式信息,与fopen函数中的打开模式相同。常用读模式“r”和写模式“w”
    */
    
    #include <stdio.h>
    #include <fcntl.h>
    
    int main()
    {
        FILE *fp;
        int fd = open("data.dat", O_WRONLY | O_CREAT | O_TRUNC); //创建文件并返回文件描述符
        if (fd == -1)
        {
            fputs("file open error", stdout);
            return -1;
        }
        fd = fdopen(fd, "w"); //返回 写 模式的 FILE 指针
        fputs("NetWork C programming \n", fp);
        fclose(fp);
        return 0;
    }
    
    //运行命令
    gcc desto.c -o desto
    ./desto
    cat data.dat  //cat命令用于连接文件并打印到标准输出设备上。
    
  • 利用 fileno 函数转换为文件描述符

    与fdopen函数功能相反。

    #include <stdio.h>
    int fileno(FILE *stream);
    /*
    成功时返回文件描述符,失败时返回 -1
    *stream  并没有改变
    */
    

15.3 基于套接字的标准I/O函数使用

? 把第四章的回声客户端和回声服务端的内容改为基于标准 I/O 函数的数据交换形式。

源码echo_stdserv.c echo_client.c P262

第十六章 关于I/O流分离的其他内容

? “流”理解为数据收发路径。

16.1 分离I/O流

  • 2次I/O流分离

    之前有两种分离方法:

    • 第 10 章的「TCP I/O 过程」分离。通过调用 fork 函数复制出一个文件描述符,以区分输入和输出中使用的文件描述符。
    • 第 15 章。通过 2 次 fdopen 函数的调用,创建读模式 FILE 指针(FILE 结构体指针)和写模式 FILE 指针。
  • 分离「流」的好处

    首先是第 10 章「流」的分离目的:

    • 通过分开输入过程(代码)和输出过程降低实现难度
    • 与输入无关的输出操作可以提高速度

    下面是第 15 章「流」分离的目的:

    • 为了将 FILE 指针按读模式和写模式加以区分
    • 可以通过区分读写模式降低实现难度
    • 通过区分 I/O 缓冲提高缓冲性能
  • 「流」分离带来的 EOF 问题

    第 7 章介绍过 EOF 的传递方法和半关闭的必要性。有一个语句:

    shutdown(sock,SHUT_WR);
    

    调用shutdown函数的基于半关闭的EOF传递方法。

    ? **但是还没有讲采用 fdopen 函数怎么半关闭。那么是否是通过 fclose 函数关闭流呢?经过源码sep_serv.c与sep_clnt.c P266**后可看出:

    ? 服务端代码的 fclose(writefp); **这一句,完全关闭了套接字而不是半关闭。**这才是这一章需要解决的问题。

16.2 文件描述符的复制和半关闭

  • 终止「流」时无法半关闭原因

    ? 服务端代码中的两个FILE 指针、文件描述符和套接字中的关系:

    ? 从图中可以看到,两个指针都是基于同一文件描述符创建的。因此,针对于任何一个 FILE 指针调用 fclose 函数都会关闭文件描述符,如图所示:

    ? 从图中看到,销毁套接字时再也无法进行数据交换。那如何进入可以进入但是无法输出的半关闭状态呢?如下图所示:

    只需要创建 FILE 指针前先复制文件描述符即可。复制后另外创建一个文件描述符,然后利用各自的文件描述符生成读模式的 FILE 指针和写模式的 FILE 指针。这就为半关闭创造好了环境,因为套接字和文件描述符具有如下关系:

    销毁所有文件描述符候才能销毁套接字.

    也就是说,针对写模式 FILE 指针调用 fclose 函数时,只能销毁与该 FILE 指针相关的文件描述符,无法销毁套接字,如下图:

    ? 调用 fclose 函数候还剩下 1 个文件描述符,因此没有销毁套接字。那此时的状态是否为半关闭状态?不是!只是准备好了进入半关闭状态,而不是已经进入了半关闭状态。仔细观察,还剩下一个文件描述符。而该文件描述符可以同时进行 I/O 。因此,不但没有发送 EOF ,而且仍然可以利用文件描述符进行输出。

    只有发送EOF,关闭发送或读取两条路的其中之一,才称得上是半关闭状态。

  • 复制文件描述符

    ? 与调用 fork 函数不同,调用 fork 函数将复制整个进程,此处讨论的是同一进程内完成对完成描述符的复制

    ? **文件描述符的值不能重复。**复制完成后,两个文件描述符都可以访问同一个文件,但是编号不同。

    “为了访问同一文件或套接字,创建另一个文件描述符。”

  • dup 和 dup2

    #include <unistd.h>
    int dup(int fildes);
    int dup2(int fildes, int fildes2);
    /*
    成功时返回复制的文件描述符,失败时返回 -1
    fildes : 需要复制的文件描述符
    fildes2 : 明确指定的文件描述符的整数值。
    */
    

    ? dup2 函数明确指定复制的文件描述符的整数值。向其传递大于 0 且小于进程能生成的最大文件描述符值时,该值将成为复制出的文件描述符值。

  • 复制文件描述符后「流」的分离

    源码sep_serv2.c P273

    调用shutdown函数,服务器端进入半关闭状态,并向客户端发送EOF。

    无论复制多少文件描述符,均应调用shutdown函数并发送EOF并进入半关闭状态。

第十七章 优于select的epoll

17.1 epoll理解及应用

  • 基于 select 的 I/O 复用技术速度慢的原因

      第 12 章实现了基于 select 的 I/O 复用技术服务端,其中有不合理的设计如下:
    
    • 调用 select 函数后常见的针对所有文件描述符的循环语句
    • 每次调用 select 函数时都需要向该函数传递监视对象信息(复制)

    ? 调用 select 函数后,并不是把发生变化的文件描述符单独集中在一起,而是通过作为监视对象的 fd_set 变量的变化,找出发生变化的文件描述符(54,56行),因此无法避免针对所有监视对象的循环语句。而且,作为监视对象的 fd_set 会发生变化,所以调用 select 函数前应该复制并保存原有信息,并在每次调用 select 函数时传递新的监视对象信息。

    ? select 性能上最大的弱点是:每次传递监视对象信息,准确的说,select 是监视套接字变化的函数。而套接字是操作系统管理的,所以 select 函数要借助操作系统才能完成功能。select 函数的这一缺点可以通过如下方式弥补:

    仅向操作系统传递一次监视对象,监视范围或内容发生变化时只通知发生变化的事项。

    Linux 的支持方式是 epoll ,Windows 的支持方式是 IOCP。

  • select 也有优点

    ? select 的兼容性比较高,这样就可以支持很多的操作系统,不受平台的限制,使用 select 函数满足以下两个条件:

    • 服务器接入者少
    • 程序应该具有兼容性
  • 实现 epoll 时必要的函数和结构体

    ? 能够克服 select 函数缺点的 epoll 函数具有以下优点,这些优点正好与之前的 select 函数缺点相反:

    • 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句
    • 调用对应于 select 函数的 epoll_wait 函数时无需每次传递监视对象信息。

    下面是 epoll 函数的功能:

    • epoll_create:创建保存 epoll 文件描述符的空间 == fd_set变量
    • epoll_ctl:向空间注册并注销文件描述符 == FD_SET、FD_CLR 函数
    • epoll_wait:与 select 函数类似,等待文件描述符发生变化

    ? select 函数中为了保存监视对象的文件描述符,直接声明了 fd_set 变量,但 epoll 方式下的操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建保存文件描述符的空间,此时用的函数就是 epoll_create 。

    ? 此外,为了添加和删除监视对象文件描述符,select 方式中需要 FD_SET、FD_CLR 函数。但在 epoll 方式中,通过 epoll_ctl 函数请求操作系统完成。最后,select 方式下调用 select 函数等待文件描述符的变化,而 epoll_wait 调用 epoll_wait 函数。还有,select 方式中通过 fd_set 变量查看监视对象的状态变化,而 epoll 方式通过如下结构体 epoll_event 将发生变化的文件描述符单独集中在一起。

    struct epoll_event
    {
        __uint32_t events;
        epoll_data_t data;
    };
    typedef union epoll_data {
        void *ptr;
        int fd;		//文件/套接字 描述符
        __uint32_t u32;
        __uint64_t u64;
    } epoll_data_t;
    

    ? 声明足够大的 epoll_event 结构体数组候,**传递给 epoll_wait 函数时,发生变化的文件描述符信息将被填入数组。**因此,无需像 select 函数那样针对所有文件描述符进行循环。

  • epoll_create

    ? epoll 是从 Linux 的 2.5.44 版内核开始引入的。通过以下命令可以查看 Linux 内核版本:

    cat /proc/sys/kernel/osrelease
    
    #include <sys/epoll.h>
    int epoll_create(int size);
    /*
    成功时返回 epoll 例程的文件描述符,失败时返回 -1
    size:epoll 实例的大小
    */
    

    ? 调用 epoll_create 函数时创建的文件描述符保存空间称为**「epoll 例程」**,但有些情况下名称不同,需要稍加注意。通过参数 size 传递的值决定 epoll 例程的大小,但该值只是向操作系统提出的建议。换言之,size 并不用来决定 epoll 的大小,而仅供操作系统参考。

    ? epoll_create 函数创建的资源与套接字相同,也由操作系统管理。因此,该函数和创建套接字的情况相同,也会返回文件描述符,也就是说返回的文件描述符主要用于区分 epoll 例程。需要终止时,与其他文件描述符相同,也要调用 close 函数。

  • epoll_ctl

    ? 生成例程后,应在其内部注册监视对象文件描述符,此时使用 epoll_ctl 函数:

    #include <sys/epoll.h>
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    /*
    成功时返回 0 ,失败时返回 -1
    epfd:用于注册监视对象的 epoll 例程的文件描述符
    op:用于制定监视对象的添加、删除或更改等操作
    fd:需要注册的监视对象文件描述符
    event:监视对象的事件类型
    */
    
    epoll_ctl(A,EPOLL_CTL_ADD,B,C);
    //epoll 例程 A 中注册文件描述符 B ,主要目的是为了监视参数 C 中的事件
    
    epoll_ctl(A,EPOLL_CTL_DEL,B,NULL);
    //从 epoll 例程 A 中删除文件描述符 B
    

    下面是第二个参数的含义:

    • EPOLL_CTL_ADD:将文件描述符注册到 epoll 例程
    • EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符
    • EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况

    ? 第四个参数类型是epoll_event结构体指针:

    ? epoll_event 结构体用于保存事件的文件描述符结合。但也可以在 epoll 例程中注册文件描述符时,用于注册关注的事件。该函数中 epoll_event 结构体的定义并不显眼,因此通过调用语句说明该结构体在 epoll_ctl 函数中的应用。

    struct epoll_event event;
    ...
    event.events=EPOLLIN;//发生需要读取数据的情况时
    event.data.fd=sockfd;
    epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&event);
    ...
    //将 epfd 注册到 epoll 例程 epfd 中,并在需要读取数据的情况下产生相应事件。
    

    epoll_event 的成员 events 中可以保存的常量及所指的事件类型:

    • EPOLLIN:需要读取数据的情况
    • EPOLLOUT:输出缓冲为空,可以立即发送数据的情况
    • EPOLLPRI:收到 OOB 数据的情况
    • EPOLLRDHUP:断开连接或半关闭的情况,这在边缘触发方式下非常有用
    • EPOLLERR:发生错误的情况
    • EPOLLET:以边缘触发的方式得到事件通知
    • EPOLLONESHOT:发生一次事件后,相应文件描述符不再收到事件通知。因此需要向 epoll_ctl 函数的第二个参数传递 EPOLL_CTL_MOD ,再次设置事件。

    可通过位运算同事传递多个上述参数。

  • epoll_wait

    #include <sys/epoll.h>
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    /*
    成功时返回发生事件的文件描述符数,失败时返回 -1
    epfd : 表示事件发生监视范围的 epoll 例程的文件描述符
    events : 保存发生事件的文件描述符集合的结构体地址值
    maxevents : 第二个参数中可以保存的最大事件数
    timeout : 以 1/1000 秒为单位的等待时间,传递 -1 时,一直等待直到发生事件
    */
    

    需要注意的是,第二个参数所指缓冲需要动态分配

    int event_cnt;
    struct epoll_event *ep_events;
    ...
    ep_events=malloc(sizeof(struct epoll_event)*EPOLL_SIZE);//EPOLL_SIZE是宏常量
    ...
    event_cnt=epoll_wait(epfd,ep_events,EPOLL_SIZE,-1);
    ...
    

    ? 调用函数后,返回发生事件的文件描述符数,同时在第二个参数指向的缓冲中保存发生事件的文件描述符集合。因此,无需像 select 一样插入针对所有文件描述符的循环。

  • 基于 epoll 的回声服务器端

    源码 echo_epollserv.c P281

17.2 条件触发和边缘触发

? 条件触发(Level Trigger)和边缘触发(Edge Trigger)

  • 条件触发和边缘触发的区别在于发生事件的时间点

    条件触发方式中,只要输入缓冲有数据就会一直通知该事件

    ? 例如,服务器端输入缓冲收到 50 字节数据时,服务器端操作系统将通知该事件(注册到发生变化的文件描述符)。但是服务器端读取 20 字节后还剩下 30 字节的情况下,仍会注册事件。也就是说,条件触发方式中,只要输入缓冲中还剩有数据,就将以事件方式再次注册。

    边缘触发中输入缓冲收到数据时仅注册 1 次该事件。即使输入缓冲中还留有数据,也不会再进行注册。

  • 掌握条件触发的事件特性

    epoll 默认以条件触发的方式工作

    源码echo_EPLTserv.c P284

    select模型是以条件触发的方式工作的。

  • 边缘触发的服务器端必知的两点

    • 通过 errno 变量验证错误原因
    • 为了完成非阻塞(Non-blocking)I/O ,更改套接字特性。

    ? Linux 套接字相关函数一般通过 -1 通知发生了错误。虽然知道发生了错误,但仅凭这些内容无法得知产生错误的原因。因此,为了在发生错误的时候提额外的信息,Linux 声明了如下全局变量:

    int errno;
    

    为了访问该变量,需要引入 error.h 头文件,因此此头文件有上述变量的 extren 声明。另外,每种函数发生错误时,保存在 errno 变量中的值都不同。

    Linux 中提供的改变和更改文件属性的办法:

    #include <fcntl.h>
    int fcntl(int fields, int cmd, ...);
    /*
    成功时返回 cmd 参数相关值,失败时返回 -1
    filedes : 属性更改目标的文件描述符
    cmd : 表示函数调用目的
    */
    

    ? 如果向第二个参数传递 F_GETFL ,可以获得第一个参数所指的文件描述符属性(int 型)。反之,如果传递 F_SETFL ,可以更改文件描述符属性。若希望将文件(套接字)改为非阻塞模式,需要如下 2 条语句:

    int flag = fcntl(fd,F_GETFL,0);
    fcntl(fd,F_SETFL, flag| O_NONBLOCK);
    

    ? 通过第一条语句,获取之前设置的属性信息,通过第二条语句在此基础上添加非阻塞 O_NONBLOCK 标志。调用 read/write 函数时,无论是否存在数据,都会形成非阻塞文件(套接字)。

  • 实现边缘触发回声服务器端

    ? 上述两者都与边缘触发的服务器端实现密切联系

    ? 通过 errno 确认错误的原因是:边缘触发方式中,接收数据仅注册一次该事件。

    ? 因为这种特点,一旦发生输入相关事件时,就应该读取输入缓冲中的全部数据。因此需要验证输入缓冲是否为空。

    read 函数发现输入缓冲中没有数据可读时返回 -1,同时在 errno 中保存 EAGAIN 常量。

    ? 边缘触发条件下,以阻塞方式工作的 read & write 函数有可能引起服务端的长时间停顿。因此,边缘触发方式中一定要采用非阻塞 read & write 函数。

    源码 echo_EPETserv.c P289

  • 条件触发和边缘触发孰优孰劣

    ? 边缘触发方式可以分离接收数据和处理数据的时间点!

    运行流程如下:

    • 服务器端分别从 A B C 接收数据
    • 服务器端按照 A B C 的顺序重新组合接收到的数据
    • 组合的数据将发送给任意主机。

    为了完成这个过程,如果可以按照如下流程运行,服务端的实现并不难:

    • 客户端按照 A B C 的顺序连接服务器,并且按照次序向服务器发送数据
    • 需要接收数据的客户端应在客户端 A B C 之前连接到服务器端并等待

    但是实际情况中可能是下面这样:

    • 客户端 C 和 B 正在向服务器发送数据,但是 A 并没有连接到服务器
    • 客户端 A B C 乱序发送数据
    • 服务端已经接收到数据,但是要接收数据的目标客户端并没有连接到服务器端。

    因此,即使输入缓冲收到数据,服务器端也能决定读取和处理这些数据的时间点,这样就给服务器端的实现带来很大灵活性

第十八章 多线程服务器端的实现

18.1 理解线程的概念

  • 引入线程背景

    多进程模型的缺点:

    • 创建进程的过程会带来一定的开销
    • 为了完成进程间数据交换,需要特殊的 IPC 技术。

    但是更大的缺点是下面的:

    • 每秒少则 10 次,多则千次的「上下文切换」是创建进程的最大开销。

    ? 只有一个 CPU 的系统是将时间分成多个微小的块后分配给了多个进程。为了分时使用 CPU ,需要「上下文切换」的过程。「上下文切换」是指运行程序前需要将相应进程信息读入内存,如果运行进程 A 后紧接着需要运行进程 B ,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息。这就是上下文切换。但是此时进程 A 的数据将被移动到硬盘,所以上下文切换要很长时间,即使通过优化加快速度,也会存在一定的局限。

    ? 为了将进程的各种劣势降至最低程度(不是直接消除)而设立的一种「轻量级进程」。线程比进程具有如下优点:

    • 线程的创建和上下文切换比进程的创建和上下文切换更快
    • 线程间交换数据无需特殊技术
  • 线程和进程的差异

    ? 线程是为了解决:为了得到多条代码执行流而复制整个内存区域的负担太重。

    ? 每个进程的内存空间都由保存全局变量的「数据区」、向 malloc 等函数动态分配提供空间的堆(Heap)、**函数运行时间使用的栈(Stack)**构成。每个进程都有独立的这种空间,多个进程的内存结构如图所示:

    ? 但如果以获得多个代码执行流为目的,则不应该像上图那样完全分离内存结构,而只需分离栈区域。通过这种方式可以获得如下优势:

    • 上下文切换时不需要切换数据区和堆
    • 可以利用数据区和堆交换数据

    这就是线程。

    ? 如图所示,多个线程共享数据区和堆。为了保持这种结构,线程将在进程内创建并运行。也就是说,进程和线程可以定义为如下形式:

    • 进程:在操作系统构成单独执行流的单位(资源分配的最小单位)
    • 线程:在进程构成单独执行流的单位(cpu调度的最小单位)

    ? 如果说进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。因此,操作系统、进程、线程之间的关系可以表示为下图:

18.2 线程创建及运行

? 可移植操作系统接口(英语:Portable Operating System Interface,缩写为POSIX)是IEEE为要在各种UNIX操作系统上运行软件,而定义API的一系列互相关联的标准的总称。

? 线程创建方法也是以POSIX标准为依据的。

  • 线程的创建和执行流程

    ? 线程具有单独的执行流,因此需要单独定义线程的 main 函数,还需要请求操作系统在单独的执行流中执行该函数,完成函数功能的函数如下:

    #include <pthread.h>
    
    int pthread_create(pthread_t *restrict thread,
                       const pthread_attr_t *restrict attr,
                       void *(*start_routine)(void *),
                       void *restrict arg);
    /*
    成功时返回 0 ,失败时返回 -1
    thread : 保存新创建线程 ID 的变量地址值。线程与进程相同,也需要用于区分不同线程的 ID
    attr : 用于传递线程属性的参数,传递 NULL 时,创建默认属性的线程
    start_routine : 相当于线程 main 函数的、在单独执行流中执行的函数地址值(函数指针)
    arg : 通过第三个参数传递的调用函数时包含传递参数信息的变量地址值
    */
    

    ? restrict,C语言中的一种类型限定符(Type Qualifiers),用于告诉编译器,对象已经被指针所引用,不能通过除该指针外所有其他直接或间接的方式修改该对象的内容。

    #include <stdio.h>
    #include <pthread.h>
    #include <unistd.h>
    void *thread_main(void *arg);
    
    int main(int argc, char *argv[])
    {
        pthread_t t_id;
        int thread_param = 5;
        // 请求创建一个线程,从 thread_main 调用开始,在单独的执行流中运行。同时传递参数
        if (pthread_create(&t_id, NULL, thread_main, (void *)&thread_param) != 0)
        {
            puts("pthread_create() error");
            return -1;
        }
        sleep(10); //延迟进程终止时间
        puts("end of main");
        return 0;
    }
    void *thread_main(void *arg) //传入的参数是 pthread_create 的第四个
    {
        int i;
        int cnt = *((int *)arg);
        for (int i = 0; i < cnt; i++)
        {
            sleep(1);
            puts("running thread");
        }
        return NULL;
    }
    

    线程相关代码在编译时需要添加 -lpthread 选项声明连接线程库,这样才能调用头文件 pthread.h 中声明的函数。

    线程执行过程:

    ? 若将16行的sleep语句换成 sleep(2); 将不会输出5次 “running thread”字符串。因为main函数返回后整个进程将被销毁,线程也会一同终止。

    “线程相关程序中必须适当调用sleep函数” ———>并非如此!!!

    通过下面的函数控制线程的执行流:

    #include <pthread.h>
    int pthread_join(pthread_t thread, void **status);
    /*
    成功时返回 0 ,失败时返回 -1
    thread : 该参数值 ID 的线程终止后才会从该函数返回
    status : 保存线程的 main 函数返回值的指针的变量地址值
    */
    

    ? 调用该函数的进程(或线程)将进入等待状态,直到第一个参数为 ID 的线程终止为止。而且可以得到线程的 main 函数的返回值。

    执行流程:

  • 可在临界区内调用的函数

    多个线程同时调用同一函数时的问题(临界区)。

    根据临界区是否引起问题,函数可以分为以下 2 类:

    • 线程安全函数(Thread-safe function):被多个线程同时调用也不会发生问题
    • 非线程安全函数(Thread-unsafe function):同时调用时会引发问题

    ? **线程安全的函数中同样可能存在临界区。**只是在线程安全的函数中,同时被多个线程调用时可通过一些措施避免问题。

    ? **大多数标准函数都是线程安全函数。**操作系统在定义非线程安全函数的同时,提供了具有相同功能的线程安全的函数。比如,第 8 章的:

    struct hostent *gethostbyname(const char *hostname);
    
    //同一功能的安全函数
    struct hostent *gethostbyname_r(const char *name,
                                    struct hostent *result,
                                    char *buffer,
                                    int intbuflen,
                                    int *h_errnop);
    

    ? 线程安全函数结尾通常是 _r 。但是使用线程安全函数会给程序员带来额外的负担,可以通过以下方法自动将 gethostbyname 函数调用改为 gethostbyname_r 函数调用。

    声明头文件前定义 _REENTRANT 宏。

    无需特意更改源代码加,可以在编译的时候指定编译参数定义宏。

    gcc -D_REENTRANT mythread.c -o mthread -lpthread
    
  • 工作(Worker)线程模型

    ? 下面的示例是计算从 1 到 10 的和,但并不是通过 main 函数进行运算,而是创建两个线程,其中一个线程计算 1 到 5 的和,另一个线程计算 6 到 10 的和,main 函数只负责输出运算结果。这种方式的编程模型称为「工作线程Worker threads 模型」,线程是main线程管理的工作。显示该程序的执行流程图:

    //thread3.c
    #include <stdio.h>
    #include <pthread.h>
    void *thread_summation(void *arg);
    int sum = 0;
    
    int main(int argc, char *argv[])
    {
        pthread_t id_t1, id_t2;
        int range1[] = {1, 5};
        int range2[] = {6, 10};
    
        pthread_create(&id_t1, NULL, thread_summation, (void *)range1);
        pthread_create(&id_t2, NULL, thread_summation, (void *)range2);
    
        pthread_join(id_t1, NULL);
        pthread_join(id_t2, NULL);
        printf("result: %d \n", sum);
        return 0;
    }
    void *thread_summation(void *arg)
    {
        int start = ((int *)arg)[0];
        int end = ((int *)arg)[1];
        while (start <= end)
        {
            sum += start;
            start++;
        }
        return NULL;
    }
    

    上述代码中,“2个线程直接访问全局变量sum”

    但此处存在临界区相关问题!!

    源码 thread4.c P304

    每次运行答案并不一样!

18.3 线程存在的问题和临界区

? 分析thread4.c 产生的问题原因 并解决

  • 多个线程访问同一变量是问题

    "2个线程直接访问全局变量sum

    此处的“访问”是指变量值的更改

    任何内存空间——只要被同时访问——都有可能发生问题

    **问题:**A线程对全局变量sum的更改还未写回到内存中,此时B线程便获取sum的值(此时的值是还未更改的旧值),B线程更改sum后写回内存,A再写回内存。此过程便存在问题。

    因此,A线程访问变量sum时应该阻止其他线程访问。

    这就是“同步”。

  • 临界区位置

    临界区:“函数内同时运行多个线程时引发问题的多条语句构成的代码块”

    临界区通常位于由线程运行的函数内部。

    //thread4.c
    void *thread_inc(void *arg)
    {
        int i;
        for (i = 0; i < 50000000; i++)
            num += 1;//临界区
        return NULL;
    }
    void *thread_des(void *arg)
    {
        int i;
        for (i = 0; i < 50000000; i++)
            num -= 1;//临界区
        return NULL;
    }
    

    由上述代码可知,临界区并非 num 本身,而是访问 num 的两条语句,这两条语句可能由多个线程同时运行,也是引起这个问题的直接原因。产生问题的原因可以分为以下三种情况:

    • 2 个线程同时执行 thread_inc 函数
    • 2 个线程同时执行 thread_des 函数
    • 2 个线程分别执行 thread_inc 和 thread_des 函数

    比如发生以下情况:

    线程 1 执行 thread_inc 的 num+=1 语句的同时,线程 2 执行 thread_des 函数的 num-=1 语句

    也就是说,两条不同的语句由不同的线程执行时,也有可能构成临界区。前提是这 2 条语句访问同一内存空间

18.4 线程同步

? 解决上述问题,方法——线程同步

  • 同步的两面性

    **线程同步用于解决线程访问顺序引发的问题。**需要同步的情况可以从如下两方面考虑。

    • 同时访问同一内存空间时发生的情况
    • 需要指定访问同一内存空间的线程顺序的情况

    ? 线程 A 负责向指定的内存空间内写入数据,线程 B 负责取走该数据。这种情况下,线程A应先访问约定的内存空间并保存数据,若B先访问并取走数据,便会产生错误。 所以这是有顺序的,不按照顺序就可能发生问题。所以这种也需要进行同步。

  • 互斥量

    互斥量(Mutual exclusion,缩写 Mutex)表示不允许多个线程同时访问。

    //互斥量的创建及销毁函数
    #include <pthread.h>
    int pthread_mutex_init(pthread_mutex_t *mutex,
                           const pthread_mutexattr_t *attr);
    int pthread_mutex_destroy(pthread_mutex_t *mutex);
    /*
    成功时返回 0,失败时返回其他值
    mutex : 创建互斥量时传递保存互斥量的变量地址值,销毁时传递需要销毁的互斥量地址
    attr : 传递即将创建的互斥量属性,没有特别需要指定的属性时传递 NULL
    */
    

    ? 如果不需要配置特殊的互斥量属性,则向第二个参数传递 NULL 时,可以利用 PTHREAD_MUTEX_INITIALIZER 进行如下声明:

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    //推荐尽可能的使用 pthread_mutex_init 函数进行初始化,因为通过宏进行初始化时很难发现发生的错误。
    
    //互斥量锁住或释放临界区
    #include <pthread.h>
    int pthread_mutex_lock(pthread_mutex_t *mutex);
    int pthread_mutex_unlock(pthread_mutex_t *mutex);
    /*
    成功时返回 0 ,失败时返回其他值
    */
    

    ? 进入临界区前调用的函数就是 pthread_mutex_lock 。调用该函数时,发现有其他线程已经进入临界区,则 pthread_mutex_lock 函数不会返回,直到里面的线程调用 pthread_mutex_unlock 函数退出临界区位置。也就是说,其他线程让出临界区之前,当前线程一直处于阻塞状态

    pthread_mutex_lock(&mutex);
    //临界区开始
    //...
    //临界区结束
    pthread_mutex_unlock(&mutex);
    

    ? 线程退出临界区时,如果忘了调用 pthread_mutex_unlock 函数,那么其他为了进入临界区而调用 pthread_mutex_lock 的函数无法摆脱阻塞状态。这种情况称为**「死锁」**。

    源码 mutex.c P312

    最大限度减少互斥量 lock unlock 函数的调用次数

  • 信号量

    信号量是共享资源的数量。

    “二进制信号量”(只用0和1)完成 “控制信号线程顺序”为中心的同步方法。

    //信号量的创建及销毁
    #include <semaphore.h>
    int sem_init(sem_t *sem, int pshared, unsigned int value);
    int sem_destroy(sem_t *sem);
    /*
    成功时返回 0 ,失败时返回其他值
    sem : 创建信号量时保存信号量的变量地址值,销毁时传递需要销毁的信号量变量地址值
    pshared : 传递其他值时,创建可由多个继承共享的信号量;传递 0 时,创建只允许 1 个进程内部使用的信号量。需要完成同一进程的线程同步,故为0
    value : 指定创建信号量的初始值
    */
    
    //信号量中相当于互斥量 lock、unlock的函数
    int sem_post(sem_t *sem);
    int sem_wait(sem_t *sem);
    /*
    成功时返回 0 ,失败时返回其他值
    sem : 传递保存信号量读取值的变量地址值,传递给 sem_post 的信号量增1,传递给 sem_wait 时信号量减一
    */
    
      调用 sem_init 函数时,操作系统将创建信号量对象,此对象中记录这「信号量值」(Semaphore Value)整数。该值在调用 sem_post 函数时增加 1 ,调用 wait_wait 函数时减一。但信号量的值不能小于 0 ,因此,**在信号量为 0 的情况下调用 sem_wait 函数时,调用的线程将进入阻塞状态**(因为函数未返回)。当然,此时如果有其他线程调用 sem_post 函数,信号量的值将变为 1 ,而原本阻塞的线程可以将该信号重新减为 0 并跳出阻塞状态。实际上就是通过这种特性完成临界区的同步操作,可以通过如下形式同步临界区(假设信号量的初始值为 1)。
    
    sem_wait(&sem);//信号量变为0...
    // 临界区的开始
    //...
    //临界区的结束
    sem_post(&sem);//信号量变为1...
    

    ? 调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。

    源码semaphore.c P315

18.5 线程的销毁和多线程并发服务器端的实现

  • 销毁线程的 3 种方法

    Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。

    • 调用 pthread_join 函数
    • 调用 pthread_detach 函数

    ? 之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。

    #include <pthread.h>
    int pthread_detach(pthread_t th);
    /*
    成功时返回 0 ,失败时返回其他值
    thread : 终止的同时需要销毁的线程 ID
    */
    

    ? 调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。

  • 多线程并发服务器端的实现

    源码chat_server.c P317

    源码chat_clnt.c P320

第二十四章 制作HTTP服务器端

24.1 HTTP概要

? HTTP(HyperText Transfer Protocol,超文本传输协议)服务器端,即 Web 服务器端。

  • 理解 Web 服务器端

    “web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客户端的服务器端。”

    Hypertext(超文本)是可以根据客户端请求而跳转的结构化信息

    HTTP协议是以超文本传输为目的而设计的应用层协议,属于基于TCP/IP实现的协议。

  • HTTP

    ? 从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称无状态的 Stateless 协议

    为了弥补HTTP无法保持连接的缺点,Web编程中通常使用Cookie和Session技术。

  • 请求消息(Request Message)的结构

    客户端向服务端发起请求消息的结构:

    ? 从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。其中“GET/index.html HTTP/1.1” 具有如下含义:

    请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。

    ? 请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。

    ? 请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。

    ? 消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。

    ? 另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题。

  • 响应消息(Response Message)的结构

    ? Web 服务器端向客户端传递的响应信息的结构。该响应消息由状态行、头信息、消息体等 3 个部分组成。

    ? 第一个字符串状态行中含有关于客户端请求的处理结果。例如,客户端请求 index.html 文件时,表示 index.html 文件是否存在、服务端是否发生问题而无法响应等不同情况的信息写入状态行。图中的「HTTP/1.1 200 OK」具有如下含义:

    “我想用HTTP1.1版本进行响应,你的请求已正确处理(200 OK)”

    表示“客户端请求的执行结果”的数字称为 状态码,典型的有一下几种:

    • 200 OK : 成功处理了请求!
    • 404 Not Found : 请求的文件不存在!
    • 400 Bad Request : 请求方式错误,请检查!

    **消息头中含有传输的数据类型和长度等信息。**图中的消息头含有如下信息:

    服务端名为 SimpleWebServer ,传输的数据类型为 text/html(html格式的文本数据)。数据长度不超过 2048 个字节。

    插入一个空行后,通过消息体发送客户端请求的文件数据。

24.2 实现简单的Web服务器端

  • 实现基于 Linux 的多线程 Web 服务器端

    源码webserv_linux.c P408

    // 临界区的开始
    //…
    //临界区的结束
    sem_post(&sem);//信号量变为1…

    
    ?		调用 sem_wait 函数进入临界区的线程在调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此,具有这种特性的机制称为「二进制信号量」。
    
    **==源码semaphore.c    P315==**
    
    

18.5 线程的销毁和多线程并发服务器端的实现

  • 销毁线程的 3 种方法

    Linux 的线程并不是在首次调用的线程 main 函数返回时自动销毁,所以利用如下方法之一加以明确。否则由线程创建的内存空间将一直存在。

    • 调用 pthread_join 函数
    • 调用 pthread_detach 函数

    ? 之前调用过 pthread_join 函数。调用该函数时,不仅会等待线程终止,还会引导线程销毁。但该函数的问题是,线程终止前,调用该函数的线程将进入阻塞状态。因此,通过如下函数调用引导线程销毁。

    #include <pthread.h>
    int pthread_detach(pthread_t th);
    /*
    成功时返回 0 ,失败时返回其他值
    thread : 终止的同时需要销毁的线程 ID
    */
    

    ? 调用上述函数不会引起线程终止或进入阻塞状态,可以通过该函数引导销毁线程创建的内存空间。调用该函数后不能再针对相应线程调用 pthread_join 函数。

  • 多线程并发服务器端的实现

    源码chat_server.c P317

    源码chat_clnt.c P320

第二十四章 制作HTTP服务器端

24.1 HTTP概要

? HTTP(HyperText Transfer Protocol,超文本传输协议)服务器端,即 Web 服务器端。

  • 理解 Web 服务器端

    “web服务器端就是要基于 HTTP 协议,将网页对应文件传输给客户端的服务器端。”

    Hypertext(超文本)是可以根据客户端请求而跳转的结构化信息

    HTTP协议是以超文本传输为目的而设计的应用层协议,属于基于TCP/IP实现的协议。

  • HTTP
    在这里插入图片描述

    ? 从上图可以看出,服务器端响应客户端请求后立即断开连接。换言之,服务器端不会维持客户端状态。即使同一客户端再次发送请求,服务器端也无法辨认出是原先那个,而会以相同方式处理新请求。因此,HTTP 又称无状态的 Stateless 协议

    为了弥补HTTP无法保持连接的缺点,Web编程中通常使用Cookie和Session技术。

  • 请求消息(Request Message)的结构

    客户端向服务端发起请求消息的结构:
    在这里插入图片描述
    ? 从图中可以看出,请求消息可以分为请求行、消息头、消息体 3 个部分。其中,请求行含有请求方式(请求目的)信息。典型的请求方式有 GET 和 POST ,GET 主要用于请求数据,POST 主要用于传输数据。其中“GET/index.html HTTP/1.1” 具有如下含义:

    请求(GET)index.html 文件,通常以 1.1 版本的 HTTP 协议进行通信。

    ? 请求行只能通过 1 行(line)发送,因此,服务器端很容易从 HTTP 请求中提取第一行,并分别分析请求行中的信息。

    ? 请求行下面的消息头中包含发送请求的浏览器信息、用户认证信息等关于 HTTP 消息的附加信息。

    ? 消息体中装有客户端向服务端传输的数据,为了装入数据,需要以 POST 方式发送请求。

    ? 另外,消息体和消息头与之间以空行隔开,因此不会发生边界问题。

  • 响应消息(Response Message)的结构

    ? Web 服务器端向客户端传递的响应信息的结构。该响应消息由状态行、头信息、消息体等 3 个部分组成。
    在这里插入图片描述
    ? 第一个字符串状态行中含有关于客户端请求的处理结果。例如,客户端请求 index.html 文件时,表示 index.html 文件是否存在、服务端是否发生问题而无法响应等不同情况的信息写入状态行。图中的「HTTP/1.1 200 OK」具有如下含义:

    “我想用HTTP1.1版本进行响应,你的请求已正确处理(200 OK)”

    表示“客户端请求的执行结果”的数字称为 状态码,典型的有一下几种:

    • 200 OK : 成功处理了请求!
    • 404 Not Found : 请求的文件不存在!
    • 400 Bad Request : 请求方式错误,请检查!

    **消息头中含有传输的数据类型和长度等信息。**图中的消息头含有如下信息:

    服务端名为 SimpleWebServer ,传输的数据类型为 text/html(html格式的文本数据)。数据长度不超过 2048 个字节。

    插入一个空行后,通过消息体发送客户端请求的文件数据。

24.2 实现简单的Web服务器端

  • 实现基于 Linux 的多线程 Web 服务器端

    源码webserv_linux.c P408

  网络协议 最新文章
使用Easyswoole 搭建简单的Websoket服务
常见的数据通信方式有哪些?
Openssl 1024bit RSA算法---公私钥获取和处
HTTPS协议的密钥交换流程
《小白WEB安全入门》03. 漏洞篇
HttpRunner4.x 安装与使用
2021-07-04
手写RPC学习笔记
K8S高可用版本部署
mySQL计算IP地址范围
上一篇文章      下一篇文章      查看所有文章
加:2021-09-14 13:44:27  更:2021-09-14 13:46:04 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/26 0:38:45-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码