说明

学习自

alt text

  • https://github.com/834810071/NetworkProgramming
  • https://www.bilibili.com/video/av33813135/

我主要的学习目标:

  • 固定住 socket 编程的基础知识
  • 如何使用 进程 编程
  • 如何使用 thread 编程
  • p1 讲解了,网络模型,理解了四层的模型

p4

  • tcp 基于字节流
  • 无边界的字节流,需要对数据进行分割
  • 粘包问题,解决方法:
    • 发送方: 发送数据前,先将数据包的长度信息发送给对方
    • 接收方: 接收到数据包长度信息后,根据长度信息,将数据包拆分为多个数据包进行处理

面向连接的 tcp 协议,需要建立连接,断开连接,保证数据传输的可靠性 缓冲传输

tcp报文格式

  • 紧急指针: 紧急数据指针,只有紧急数据才会使用紧急指针,紧急数据是指需要立即传输的数据
  • 选项字段: 选项字段,可以携带一些控制信息,如窗口大小,最大报文段长度,时间戳等

tcp三次握手

  • 第一次握手: 客户端发送 SYN 报文,服务器收到后,发送 SYN+ACK 报文,客户端收到后,发送 ACK 报文
  • 第二次握手: 客户端发送 ACK 报文,服务器收到后,发送 ACK 报文
  • 第三次握手: 客户端发送 ACK 报文,服务器收到后,连接建立完成

tcp四次挥手

  • 第一次挥手: 客户端发送 FIN 报文,服务器收到后,发送 ACK 报文,客户端收到后,进入 TIME-WAIT 状态
  • 第二次挥手: 服务器发送 FIN 报文,客户端收到后,发送 ACK 报文,服务器收到后,进入 CLOSED 状态
  • 第三次挥手: 服务器发送 ACK 报文,客户端收到后,进入 CLOSED 状态
  • 第四次挥手: 客户端发送 ACK 报文,服务器收到后,进入 CLOSED 状态

tcp拥塞控制

  • 慢启动: 开始时,发送方的发送速率较低,逐渐增大,以便逼近网络的实际速率
  • 拥塞避免: 网络拥塞时,发送方的发送速率降低,以防止网络过载
  • 快速重传: 接收方收到重复的 ACK 报文,认为是丢包,立即重传丢失的报文段
  • 超时重传: 发送方在一段时间内没有收到 ACK 报文,认为报文丢失,重新发送
  • 快速恢复: 当网络拥塞程度减缓时,发送方的发送速率增加,以便减轻网络拥塞

滑动窗口协议

  • 发送方维护一个窗口,指示自己可以发送的报文段数量
  • 接收方维护一个窗口,指示自己可以接收的报文段数量
  • 发送方根据接收方的反馈,调整窗口的大小,以便在网络拥塞时,减少发送报文段的数量
  • 接收方根据发送方的反馈,调整窗口的大小,以便在网络拥塞时,减少接收报文段的数量

学习目标

mindmap
    root((学习目标))
        1.什么是socket
        2.IPv4套接口地址结构
        3.网终字节序
        4.字节序转换函数
        5.地址转换函数
        6.套接字类型

1. 什么是socket

  • socket可以看成是用户进程与内核网络协议栈的 编程接口
  • socket不仅可以用于本机的进程间通信,还可以 用于网络上不同主机的进程间通信

我的理解: socket就是对前面学习的tcp/ip的代码封装,但是位于系统的内核,也就是说提供给用户的一组API,用于网络通信.

warning

socket 这里理解成"插座"

mindmap
    root((socket需要的属性))
        地址: sockaddr_in
            sin_addr
            sin_port
            sin_family

ipv4 socket 地址结构体

ipv4 socket地址结构体,在头文件netinet/in.h中定义。 sockaddr_in结构体定义如下:

使用man 7 ip命令查看sockaddr_in结构体的定义:

struct sockaddr_in {
    sa_family_t sin_family; // 地址族,ipv4 就是AF_INET
    in_port_t sin_port; // 端口号    
    struct in_addr sin_addr; // IP地址
    char sin_zero[8]; // 未使用,注意这个在man 7 ip中没有说明
                     //暗示程序员不需要知道这个??
                     //当成上面的普通的来用?
};
  1. sin_family字段表示地址族,socket在设计的时候还可以用于其它的协议,不仅是tcp/ip,比如还有unix域,对于IPv4地址,该值为AF_INET
  2. sin_port字段表示端口号,使用网络字节序(big-endian)存储。
  3. sin_addr字段表示IP地址,使用struct in_addr结构体表示。

struct in_addr结构体定义如下:

struct in_addr {
    uint32_t s_addr; // IP地址,使用网络字节序存储
};
  1. s_addr字段表示IP地址,使用网络字节序存储。

疑问1

note

到这里就有一个疑问,socketaddr_in结构体具体的定义是什么呢?后面有没有char sin_zero[8];字段呢?通过下面的代码来验证

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/ip.h> /* superset of previous */

int main (int argc, char *argv[]) {
    printf("size bytes of sockaddr_in %ld\n",sizeof(struct sockaddr_in));
    printf("size bytes of sin_family %ld\n",sizeof(sa_family_t));
    printf("size bytes of sin_port %ld\n",sizeof(in_port_t));
    printf("size bytes of sin_addr %ld\n",sizeof(struct in_addr));
    return 0;
}

输出的结果是:16,

size bytes of sockaddr_in 16
size bytes of sin_family 2
size bytes of sin_port 2
size bytes of sin_addr 4

结果确实是少8

通用套接字地址结构体sockaddr

定义如下:

它适用于任何地址协议,

struct sockaddr {
    sa_family_t sa_family; // 地址族
    char sa_data[14]; // 地址数据,长度取决于地址族
};
  1. sa_family字段表示地址族。
  2. sa_data字段表示地址数据,长度取决于地址族。
#include <stdio.h>
#include <netinet/in.h>

int main(int argc, char const *argv[])
{
    struct  sockaddr_in addr1;
    struct  sockaddr addr2;

    printf("sizof(sockaddr_in) = %ld\n", sizeof(addr1));
    printf("sizof(sockaddr) = %ld\n", sizeof(addr2));

    // 最后输出的结果是:
    // sizof(sockaddr_in) = 16
    // sizof(sockaddr) = 16
    // 说明sockaddr_in和sockaddr的大小是相同的,都是16字节。
    
    return 0;
}

在通常的网络编程中,我们先填写sockaddr_in结构体,然后再把它转换成通用的sockaddr结构体.

网络字节序

网络字节序

字节序分为:

  • 大端字节序(Big-endian)
  • 小端字节序(Little-endian)

alt text

网络字节序(Network Byte Order)是指数据的高位字节保存在内存的低地址中,而数据的低位字节保存在内存的高地址中。就是大端字节序.

在TCP/IP协议中,所有长度字段和数字都采用网络字节序。例如,在TCP报文头部的源端口字段中,端口号的高位字节保存在内存的低地址中,而端口号的低位字节保存在内存的高地址中。

说明

作者: 韩国 - 尹圣雨

alt text

学习资源

第一章 理解网络编程和socket

![INFO] 网络编程就是编写程序使用两台连网的计算机相互交换数据.

这就是整个网络编程的全部内容.我觉得不需要去详细的理解TCP/IP的细节.

下面我按我的理解这一章的内容.

如果我们想到在电脑上创建一个server端的软件,用来接收其它电脑上另一个程序通过网络发来的信息,应该如何做呢? 应该按下面的流程来做

---
title: server socket 创建流程
---
flowchart TD
    step_1[1.创建socket,API:socket]
    step_2[2.绑定端口与ip: bind]
    step_3[3.开启监听 listen]
    step_4[4.接受产生的socket: accept ]
    step_1 --> step_2 --> step_3 --> step_4;

第一步创建 socket(插座)

alt text

如果我们家里需要网络通信,首先要有一个网络面板(网络插座)用来连接网络,然后才能通信. 同样我们也需要先创建一个socket,这个socket的作用就是用于后面的网络连接.

int socket(int domain,int type,int protocal)

具体参数的含义与理解见一下章节

第二步 bind函数

通过第一步我们创建了一个socket(插座),那么应该把它放在哪里呢? 其实就是根据哪些信息可以找到这个socket.

  1. 信息首先要能发送给电脑,也就是需要知道电脑的IP,一个电脑可以有多个IP.

多网口

比如这个电脑就有两个网口,(单网口也可以有多IP)

  1. 可以想像到一个电脑上能进行网络通信的软件有多个,那么就有很多个socket.

2

3

4

电脑为了和外部通信,创建了端口这个概念.

你可以把端口想像成电脑一个一个孔洞,每一个数据通过这个孔洞发流入进电脑,如何你的socket"安装"在这个端口上,那么就可以接收到这个数据了.

然后这个socket和还需要和某一端口绑定在一起.

#include <sys/socket.h>
int bind(int sockfd,struct sockaddr_in * myaddr,socklen_t addrlen)

第三步: 激活监听,listen

现在我们有了一个socket,并把它放到了合适的位置(bind),接下就能通信了吗? 不能,还需要启用,激活它,就像家里弄了网络面板,网线,还需要网络公司给我们开通网络权限.

int listen(int sockfd,int backlog)

listen的其实是在socket创建一个监听队列,队列大小为backlog,每一个client请求连接后,就会加入到队列里,等待处理

第四步: accept,处理连接请求

int accept(int sockfd,struct sockaddr * addr ,socklen_t * addrlen);

上一步,可以接收连接,然后加入到队列里,但是还需要对这些队列里的内容进行处理.

accept的作用,从队列取出一个连接数据,然后创建一个socket.你可能会问,为什么又创建了一个socket,原来不是有一个吗?

首先我们要明确的知道一个概念,两个socket一在一起才能进行通信,或者说通信的两个电脑之间各有一个socket,不允许一socket对应多个socket.

第一个socket,我们的成为socket,我们称为server socket,它在同一时刻与一个client socket进行连接,然后接收到必要的信息之后.根据这些信息产生一个socket A.这个新的socket A已后只与这个client socket进行通信.

这个过程我们称为accept

server端代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>


#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>



const int port = 8989; //端口

char message[] ="Hello World!";

void error_handle(char *message) {
    printf("error: %s\n",message);
}

int main(int argc, char *argv[])
{

    //创建一个server 端的socket
    // man 7 socket, man 2 socket
    int serv_sock = socket(AF_INET,SOCK_STREAM,0);
    if(serv_sock == -1) {
        error_handle("socket() failed!");
        return 1;
    }

    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;

    //清空
    // man 7 ip
    memset(&serv_addr,0,sizeof(serv_addr));
    //赋值
    serv_addr.sin_family = AF_INET;
    //host to network short
    //short 16位 2字节
    serv_addr.sin_port = htons(port);
    //host to network long 4字节
    // INADDR_ANY man 7 ip
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    // 第二步: bind 端口与ip
    //通用套接字地址结构体sockaddr
    if( bind(serv_sock,(struct sockaddr *)&serv_addr,sizeof(serv_addr) ) == -1) {
        error_handle("bind() failed!");
        return 1;
    }

    //第三步: 激活监听
    if( listen(serv_sock,5) == -1) {
        error_handle("listen() failed!");
        return 1;
    }

    //第四步: 等待连接
    socklen_t client_addr_size = sizeof(clnt_addr);
    int clnt_sock = accept(serv_sock,(struct sockaddr *) &clnt_addr,&client_addr_size);
    if(clnt_sock == -1)
    {
        error_handle("accept() error!");
        return 1;
    }
    write(clnt_sock,message,sizeof(message));
    close(clnt_sock);
    close(serv_sock);
    return 0;
    return EXIT_SUCCESS;
}

编译

gcc -g -o server 1-hello_world_server.c

测试

netcat -v 127.0.0.1 8989

结果

➞  netcat -v 127.0.0.1 8989                                                                                                                                  
localhost [127.0.0.1] 8989 (sunwebadmins) open
Hello World!%

client端代码

client实现比较简单

  1. 创建socket
  2. 调用connect,与server socket进行连接
  3. 发送数据
  4. 接收返回的数据
  5. 结束client socket
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8989

void error_handle(char *message)
{
    printf("error: %s\n", message);
}

int main()
{
    // 创建一个client 端的socket
    //  man 7 socket, man 2 socket
    int clnt_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (clnt_sock == -1)
    {
        error_handle("socket() failed!");
        return 1;
    }

    // 连接到服务器
    struct sockaddr_in clnt_addr;
    memset(&clnt_addr, 0, sizeof(clnt_addr));
    clnt_addr.sin_family = AF_INET;
    //inet_addr man 7 ip
    clnt_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
    clnt_addr.sin_port = htons(SERVER_PORT);
    if (connect(clnt_sock, (struct sockaddr *)&clnt_addr, sizeof(clnt_addr)) == -1)
    {
        error_handle("connect() failed!");
        return 1;
    }
    // 发送数据
    // send(clnt_sock, message, sizeof(message), 0);
    // 接收数据
    char buffer[1024];
    int str_len = read(clnt_sock, buffer, sizeof(buffer) - 1);
    if (str_len == -1)
    {
        error_handle("read() failed!");
        return 1;
    }
    else
    {
        buffer[str_len] = '\0';
        printf("Message from server: %s\n", buffer);
    }
    close(clnt_sock);
    return 0;
}

运行结果

➞  ./1-hello_world_client.out
Message from server: Hello World!

inet_addr 函数

inet_addr 是一个用于将 IPv4 地址从点分十进制字符串表示转换为网络字节序的 32 位二进制值的函数。它在 Linux 和其他类 Unix 系统的网络编程中广泛使用。

man 3 inet_addr

函数原型:

#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);

参数:

  • cp:以点分十进制格式表示的 IPv4 地址字符串,例如 "192.168.0.1"。

返回值:

  • 成功时,返回转换后的 32 位网络字节序的 IPv4 地址。
  • 失败时,返回 INADDR_NONE(通常为 -1)。

inet_addr 函数将点分十进制格式的 IPv4 地址字符串转换为网络字节序的 32 位二进制值。它执行以下步骤:

  1. 将点分十进制的 IPv4 地址字符串按照点号分隔符分割为四个部分,每个部分表示一个字节。
  2. 将每个部分转换为对应的整数值(0-255)。
  3. 将四个字节组合成一个 32 位的二进制值,并按照网络字节序排列。

例如,对于 IP 地址 "192.168.0.1",inet_addr 函数将返回一个 32 位的整数值 0x0100A8C0(网络字节序)。

使用示例:

#include <stdio.h>
#include <arpa/inet.h>

int main() {
    const char *ip_str = "192.168.0.1";
    in_addr_t ip_addr = inet_addr(ip_str);
    
    if (ip_addr == INADDR_NONE) {
        printf("无效的 IP 地址\n");
    } else {
        printf("IP 地址的网络字节序表示: 0x%08X\n", ip_addr);
    }
    
    return 0;
}

输出:

IP 地址的网络字节序表示: 0x0100A8C0

需要注意的是,inet_addr 函数只支持 IPv4 地址的转换,对于 IPv6 地址,需要使用 inet_pton 函数进行转换。

另外,由于 inet_addr 函数不能处理格式错误的 IP 地址字符串,因此在实际使用中,通常推荐使用更加健壮和通用的 inet_pton 函数来进行 IP 地址的转换。

linux的文件操作

每一个程序都有多个文件描述符,从 0 到 1023,这些0,1,2文件描述符被称为标准输入、标准输出、标准错误。

这个文件描述符是由linux系统维护的.

int open(const char *pathname, int flags);
int close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

socket类型与协议设置

下我只与我理解的内容,如果全部都写,那么就变在了抄书了.

int socket(int domain, int type, int protocol);

其中的domain参数指定协议族,常见的有AF_INET, AF_INET6, AF_UNIX等,分别对应IPv4, IPv6, Unix Domain Socket.

type参数指定socket类型,常见的有SOCK_STREAM, SOCK_DGRAM, SOCK_RAW, SOCK_SEQPACKET等,分别对应TCP流式socket, UDP数据报式socket, 原始套接字, 顺序包套接字等.

protocol参数指定协议,常见的有IPPROTO_TCP, IPPROTO_UDP, IPPROTO_RAW, IPPROTO_SCTP等,分别对应TCP, UDP, 原始协议, SCTP等.

一般情况下,我们只需要设置domaintype参数,因为protocol参数一般由系统自动设置.

例如,创建一个TCP流式socket,可以使用如下代码:

这个用到的最多,其实也只用到这个(我们自己写的简单代码).

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

创建一个UDP数据报式socket,可以使用如下代码:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

创建一个原始套接字,可以使用如下代码:

int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);

传输的数据不存在数据边界

书上告诉我们,socket数据的传输是无边界的,也就是说,我们发送的数据可能被拆分成多个包,也可能被粘包,接收方也可能一次接收多个包,这取决于网络的传输速率和接收方处理能力.

这里给出一个我根据书上写的client.c代码,用来接收TCP流式socket数据:

它一次只能读取一个字节.

int tot = 0;
int read_len;
char buf[1024]
while(1) {
    //一次读取一个字节
    read_len = read(sockfd, buf, 1);
    if( read_len == -1) break;
    //输出读取的字符
    printf("%c",buf[0]);
    tot += read_len;
}

地址族与数据序列

书上讲解了一些ipv4地址的概念,以及ipv6地址的概念。

又说了一些端口号的概念,以及端口号的作用。

但是还是很复杂的,直接理解我第一章节说的内容,每个概念只有一句话.但是这是建立在我对网络编程的了解基础上的.

下讲解了

mindmap
    root((sockadd_in))
        sin_family(sin_family)
            AF_INET(AF_INET)
            AF_INET6(AF_INET6)
            AF_Local(AF_Local)
        sin_port(sin_port)
            htons("htons(int)")
        sin_addr(sin_addr)
            s_addr(s_addr)
                inet_addr("inet_addr(char*)")

需要把sockaddr_in转化成struct sockaddr *才能使用bind()等函数.

网络字节序与地址的转换

  • htons(int):将int类型整数转换为网络字节序。
  • htonl(int):将int类型整数转换为网络字节序。
  • ntohs(int):将int类型整数转换为主机字节序。
  • ntohl(int):将int类型整数转换为主机字节序。
  • inet_addr(char*):将char*类型字符串转换为in_addr类型。
  • inet_aton(char*, struct in_addr*):将char*类型字符串转换为in_addr类型。
  • inet_ntoa(struct in_addr):将in_addr类型转换为char*类型字符串,失败返回-1

每次创建服务器端socket都要输入IP地址会有些繁琐,此时可以使用如下的初始化地址信息

struct sockaddr_in server_addr;
char * serv_port = "8989";
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(atoi(serv_port));
server_addr.sin_addr.s_addr = inet_addr(INADDR_ANY);

基于tcp的服务器端/客户端

详细的内容看这里,我就不复制了:

https://github.com/riba2534/TCP-IP-NetworkNote/blob/master/ch04/README.md

TCP/IP 协议栈共分为 4 层,可以理解为数据收发分成了 4 个层次化过程,通过层次化的方式来解决问题

  • 链路层: 物理层,负责网络设备之间的通信,如网卡、网线等
  • IP层: 数据包进行中线选择
  • TCP/UDP层: 负责数据包的传输,保证数据包的可靠性和顺序性

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

代码:

hello_server

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock;
    int clnt_sock;

    struct sockaddr_in serv_addr;
    struct sockaddr_in clnt_addr;
    socklen_t clnt_addr_size;

    char message[] = "Hello World!";

    if (argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }
    //调用 socket 函数创建套接字
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket() error");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(atoi(argv[1]));
    //调用 bind 函数分配ip地址和端口号
    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("bind() error");
    //调用 listen 函数将套接字转为可接受连接状态
    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    clnt_addr_size = sizeof(clnt_addr);
    //调用 accept 函数受理连接请求。如果在没有连接请求的情况下调用该函数,则不会返回,直到有连接请求为止
    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_size);
    if (clnt_sock == -1)
        error_handling("accept() error");
    //稍后要将介绍的 write 函数用于传输数据,若程序经过 accept 这一行执行到本行,则说明已经有了连接请求
    write(clnt_sock, message, sizeof(message));
    close(clnt_sock);
    close(serv_sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

代码: hello_client

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    struct sockaddr_in serv_addr;
    char message[30];
    int str_len;

    if (argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }
    //创建套接字,此时套接字并不马上分为服务端和客户端。如果紧接着调用 bind,listen 函数,将成为服务器套接字
    //如果调用 connect 函数,将成为客户端套接字
    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        error_handling("socket() error");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_addr.sin_port = htons(atoi(argv[2]));
    //调用 connect 函数向服务器发送连接请求
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("connect() error!");

    str_len = read(sock, message, sizeof(message) - 1);
    if (str_len == -1)
        error_handling("read() error!");

    printf("Message from server : %s \n", message);
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

echo服务器/客户端

程序运行的基本方式:

  • 服务器端在同一时刻只与一个客户端相连,并提供回声服务。
  • 服务器端依次向 5 个客户端提供服务并退出。for循环5次
  • 客户端接受用户输入的字符串并发送到服务器端。
  • 服务器端将接受的字符串数据传回客户端,即「回声」
  • 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    char message[BUF_SIZE];
    int str_len, i;

    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;

    if (argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket() error");

    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");

    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    clnt_adr_sz = sizeof(clnt_adr);
    //调用 5 次 accept 函数,共为 5 个客户端提供服务
    for (i = 0; i < 5; i++)
    {
        clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
        if (clnt_sock == -1)
            error_handling("accept() error");
        else
            printf("Connect client %d \n", i + 1);

        while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
            write(clnt_sock, message, str_len);

        close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 1024
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    char message[BUF_SIZE];
    int str_len;
    struct sockaddr_in serv_adr;

    if (argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        error_handling("socket() error");

    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));

    if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
    else
        puts("Connected...........");

    while (1)
    {
        fputs("Input message(Q to quit): ", stdout);
        fgets(message, BUF_SIZE, stdin);

        if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
            break;

        write(sock, message, strlen(message));
        str_len = read(sock, message, BUF_SIZE - 1);
        message[str_len] = 0;
        printf("Message from server: %s", message);
    }
    close(sock);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

FAQ

问: 我在在linux下使用socket进行tcp网络编程,如果client端调用了close(sock)函数,那么server端口的read函数会读取到什么?

答:

在Linux下使用socket进行TCP网络编程时,如果客户端调用了close(sock)函数,服务器端的read函数的行为取决于客户端关闭连接的方式和时机。

通常情况下,当客户端调用close(sock)函数时,会向服务器发送一个FIN包,表示客户端想要关闭连接。服务器收到FIN包后,会做出以下响应:

  1. 如果服务器已经读取了所有客户端发送的数据,那么read函数会返回0,表示已经到达文件末尾(EOF)。这意味着客户端已经关闭了连接,并且没有更多的数据可读。

  2. 如果服务器还没有读取完客户端发送的所有数据,那么read函数会继续读取剩余的数据,直到读取完所有数据或者遇到错误为止。在这种情况下,read函数会返回实际读取到的字节数。当所有数据读取完毕后,再次调用read函数会返回0,表示已经到达文件末尾(EOF)。

  3. 如果客户端在发送数据的过程中突然关闭了连接,那么服务器的read函数可能会返回一个错误,通常是ECONNRESET错误,表示连接被重置。

需要注意的是,如果客户端在关闭连接之前没有发送所有的数据,那么服务器可能会丢失一部分数据。为了确保数据的完整性,通常建议在关闭连接之前,先调用shutdown(sock, SHUT_WR)函数,告诉操作系统不再发送数据,然后再调用close(sock)函数关闭连接。

总之,服务器端的read函数在客户端关闭连接时的行为取决于具体的情况:

  • 如果客户端发送了所有数据并关闭连接,read函数会返回0,表示EOF。
  • 如果客户端关闭连接时还有未读取的数据,read函数会继续读取剩余数据,直到读取完毕,然后返回0,表示EOF。
  • 如果客户端异常关闭连接,read函数可能会返回错误,如ECONNRESET

作为服务器端,需要正确处理这些不同的情况,以确保程序的正确性和鲁棒性。

问: 如何检测到 read函数返回一个错误, 例如是ECONNRESET?

在Linux系统中,当read函数返回错误时,会设置全局变量errno来指示具体的错误类型。你可以通过检查errno的值来判断是否发生了ECONNRESET错误。

下面是一个示例代码,演示了如何检测read函数返回的错误:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

ssize_t read_data(int sockfd, void *buf, size_t len) {
    ssize_t num_read = read(sockfd, buf, len);
    if (num_read == -1) {
        if (errno == ECONNRESET) {
            printf("Connection reset by peer\n");
        } else {
            perror("read");
        }
        return -1;
    }
    return num_read;
}

在上面的代码中,read_data函数封装了read函数的调用。它检查read函数的返回值:

  • 如果返回值为-1,表示发生了错误。此时,通过检查errno的值来判断具体的错误类型:
    • 如果errno的值等于ECONNRESET,表示连接被对端重置,可以输出相应的错误信息。
    • 否则,可以使用perror函数输出错误信息,perror函数会根据errno的值自动生成对应的错误描述。
  • 如果返回值不为-1,表示读取成功,函数返回实际读取到的字节数。

在实际使用时,你可以根据需要对read_data函数进行调用,并根据返回值进行相应的处理:

char buffer[1024];
ssize_t num_read = read_data(sockfd, buffer, sizeof(buffer));
if (num_read == -1) {
    // 处理错误情况
} else if (num_read == 0) {
    // 处理连接关闭的情况
} else {
    // 处理成功读取数据的情况
}

通过检查read_data函数的返回值,你可以判断是否发生了错误,并根据具体的错误类型采取相应的处理措施。

总之,通过检查read函数的返回值和errno的值,你可以检测并处理read函数返回的错误,包括ECONNRESET错误。这样可以增强程序的健壮性和可靠性。

基于tcp的服务器端/客户端(2)

本章节详细的讲解了TCP 中必要的理论知识

回声客户端问题的解决办法 这个问题其实很容易解决,因为可以提前接受数据的大小。若之前传输了20字节长的字符串,则再接收时循环调用 read 函数读取 20 个字节即可。既然有了解决办法,核心那么代码如下:


//得到发送的数据大小
str_len = write(sockfd,message,sizeof(message));
int recv_len = 0;
while( recv_len < str_len ) {
    int recv_cnt += read(sockfd,recv_buf,str_len-recv_len);
    //对方关闭了连接
    if( recv_cnt == 0 ) {
        break;
    }
    if( recv_cnt < 0 ) {
        error_handing("read error")!
    }
    recv_len += recv_cnt;
}
message[recv_len] = '\0';
printf("Received message: %s\n",message);

TCP 原理

  1. TCP socket中的I/O缓冲

  2. IO缓冲在每个TCP socket中单独存在.

  3. IO缓冲在在创建TCP socket自动生成.

  4. 即使关闭socket 也会继续传递输出缓冲中遗留的数据.

  5. 关闭socket会丢失输入缓冲中的数据.

不会发生超过输入缓冲大小的数据传输!

write函数会在数据移到输出缓冲时返回.

1. TCP连接

3次握手(three-way handshaking)

[SYN] SEQ:1000 ACK:NULL
[SYN+ACK] SEQ: 2000, ACK: 1001
[ACK] SEQ: 1001, ACK: 2001

2. TCP传输

3. TCP断开

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

主机 A 发送完最后的数据后,调用 close 函数断开了最后的连接,之后主机 A 无法再接受主机 B 传输的数据。实际上,是完全无法调用与接受数据相关的函数。最终,由主机 B 传输的、主机 A 必须要接受的数据也销毁了。

2. shutdown函数的作用

#include <sys/socket.h>
int shutdown(int sock, int howto);
/*
成功时返回 0 ,失败时返回 -1
sock: 需要断开套接字文件描述符
howto: 传递断开方式信息
*/

调用上述函数时,第二个参数决定断开连接的方式,其值如下所示:

  • SHUT_RD : 断开输入流
  • SHUT_WR : 断开输出流
  • SHUT_RDWR : 同时断开 I/O 流

若向 shutdown 的第二个参数传递SHUT_RD,则断开输入流,套接字无法接收数据。即使输入缓冲收到数据也会抹去,而且无法调用相关函数。如果向 shutdown 的第二个参数传递SHUT_WR,则中断输出流,也就无法传输数据。若如果输出缓冲中还有未传输的数据,则将传递给目标主机。最后,若传递关键字SHUT_RDWR,则同时中断 I/O 流。这相当于分 2 次调用 shutdown ,其中一次以SHUT_RD为参数,另一次以SHUT_WR为参数

3. 为什么要半关闭连接

留下足够的时间,等所有的数据交换完不就行了,好像也没有必要使用半关闭.

考虑以下情况:

一旦客户端连接到服务器,服务器将约定的文件传输给客户端,客户端收到后发送字符串「Thank you」给服务器端。

此处「Thank you」的传递是多余的,这只是用来模拟客户端断开连接前还有数据要传输的情况。此时程序的还嫌难度并不小,因为传输文件的服务器端只需连续传输文件数据即可,而客户端无法知道需要接收数据到何时。客户端也没办法无休止的调用输入函数,因为这有可能导致程序阻塞

是否可以让服务器和客户端约定一个代表文件尾的字符?

这种方式也有问题,因为这意味这文件中不能有与约定字符相同的内容。为了解决该问题,服务端应最后向客户端传递 EOF 表示文件传输结束。客户端通过函数返回值接受 EOF ,这样可以避免与文件内容冲突。那么问题来了,服务端如何传递 EOF ?

断开输出流时向主机传输 EOF。

当然,调用 close 函数的同时关闭 I/O 流,这样也会向对方发送 EOF 。但此时无法再接受对方传输的数据。换言之,若调用 close 函数关闭流,就无法接受客户端最后发送的字符串「Thank you」。这时需要调用 shutdown 函数,只关闭服务器的输出流。这样既可以发送 EOF ,同时又保留了输入流。下面实现收发文件的服务器端/客户端。

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

alt text

server代码

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int serv_sd, clnt_sd;
    char buf[BUF_SIZE];

    struct sockaddr_in serv_adr, clnt_adr;

    //1.创建 server_socket
    serv_sd = socket(PF_INET, SOCK_STREAM, 0);

    //2. 设定地址
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(8899);

    //3 进行bind
    bind(serv_sd, (struct sockaddr *)&serv_adr, sizeof(serv_adr));
    //4 监听
    listen(serv_sd, 5);

    socklen_t clnt_adr_sz = sizeof(clnt_adr);
    clnt_sd =  accept(serv_sd, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);

    FILE *fp = fopen("file_server.c", "rb");

    while (1)
    {
        int read_cnt = fread(buf, 1, BUF_SIZE, fp);
        if (read_cnt < BUF_SIZE)
        {
            write(clnt_sd, buf, read_cnt);
            break;
        }
        write(clnt_sd, buf, BUF_SIZE);
    }

    //文件发送完毕
    shutdown(clnt_sd, SHUT_WR); //半关闭,写入
    read(clnt_sd, buf, BUF_SIZE); //等待读取
    printf("Received message from client: %s\n", buf);
    fclose(fp);
    close(clnt_sd);
    close(serv_sd);
    return 0;
    return 0;
}

void error_handling(char *message) {
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

client代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sd;
    FILE *fp;

    char buf[BUF_SIZE];
    int read_cnt;
    struct sockaddr_in serv_adr;

    if (argc != 3)
    {
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    fp = fopen("receive.cpp", "wb");
    sd = socket(PF_INET, SOCK_STREAM, 0);

    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));

    connect(sd, (struct sockaddr *)&serv_adr, sizeof(serv_adr));

    while ((read_cnt = read(sd, buf, BUF_SIZE)) != 0)
        fwrite((void *)buf, 1, read_cnt, fp);

    puts("Received file data");
    write(sd, "Thank you", 10);
    fclose(fp);
    close(sd);
    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

第 8 章 域名及网络地址

本章代码,在TCP-IP-NetworkNote中可以找到。

8.1 域名系统

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

8.1.1 什么是域名

域名就是我们常常在地址栏里面输入的地址,将比较难记忆的IP地址变成人类容易理解的信息。

8.1.2 DNS 服务器

相当于一个字典,可以查询出某一个域名对应的IP地址

如图所示,显示了 DNS 服务器的查询路径。

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

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

一句话,需要,因为IP地址可能经常改变,而且也不容易记忆,通过域名可以随时更改解析,达到更换IP的目的

8.2.2 利用域名获取IP地址

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

#include <netdb.h>
struct hostent *gethostbyname(const char *hostname);
/*
成功时返回 hostent 结构体地址,失败时返回 NULL 指针
*/

这个函数使用方便,只要传递字符串,就可以返回域名对应的IP地址。只是返回时,地址信息装入 hostent 结构体。此结构体的定义如下:

struct hostent
{
    char *h_name;       /* Official name of host.  */
    char **h_aliases;   /* Alias list.  */
    int h_addrtype;     /* Host address type.  */
    int h_length;       /* Length of address.  */
    char **h_addr_list; /* List of addresses from name server.  */
};

从上述结构体可以看出,不止返回IP信息,同事还带着其他信息一起返回。域名转换成IP时只需要关注 h_addr_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地址信息。

调用 gethostbyname 函数后,返回的结构体变量如图所示:

下面的代码通过一个例子来演示 gethostbyname 的应用,并说明 hostent 结构体变量特性。

编译运行:

gcc gethostbyname.c -o hostname
./hostname www.baidu.com

结果:

如图所示,显示出了对百度的域名解析

可以看出,百度有一个域名解析是 CNAME 解析的,指向了shifen.com,关于百度具体的解析过程。

这一部分牵扯到了很多关于DNS解析的过程,还有 Linux 下关于域名解析的一些命令,我找了一部分资料,可以点下面的链接查看比较详细的:

仔细阅读这一段代码:

inet_ntoa(*(struct in_addr *)host->h_addr_list[i])

若只看 hostent 的定义,结构体成员 h_addr_list 指向字符串指针数组(由多个字符串地址构成的数组)。但是字符串指针数组保存的元素实际指向的是 in_addr 结构体变量中地址值而非字符串,也就是说(struct in_addr *)host->h_addr_list[i]其实是一个指针,然后用*符号取具体的值。如图所示:

8.2.3 利用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
*/

下面的代码演示使用方法:

编译运行:

gcc gethostbyaddr.c -o hostaddr
./hostaddr 8.8.8.8

结果:

从图上可以看出,8.8.8.8这个IP地址是谷歌的。

8.3 基于 Windows 的实现

暂略

8.4 习题

以下答案仅代表本人个人观点,可能不是正确答案。

  1. 下列关于DNS的说法错误的是?

    答:字体加粗的表示正确答案。

    1. 因为DNS从存在,故可以使用域名代替IP
    2. DNS服务器实际上是路由器,因为路由器根据域名决定数据的路径
    3. 所有域名信息并非集中与 1 台 DNS 服务器,但可以获取某一 DNS 服务器中未注册的所有地址
    4. DNS 服务器根据操作系统进行区分,Windows 下的 DNS 服务器和 Linux 下的 DNS 服务器是不同的。
  2. 阅读如下对话,并说明东秀的方案是否可行。(因为对话的字太多,用图代替)

    答:答案就是可行,DNS 服务器是分布式的,一台坏了可以找其他的。

  3. 在浏览器地址输入 www.orentec.co.kr ,并整理出主页显示过程。假设浏览器访问默认 DNS 服务器中并没有关于 www.orentec.co.kr 的地址信息.

    答:可以参考一下知乎回答,在浏览器地址栏输入一个URL后回车,背后会进行哪些技术步骤?,我用我自己的理解,简单说一下,首先会去向上一级的 DNS 服务器去查询,通过这种方式逐级向上传递信息,一直到达根服务器时,它知道应该向哪个 DNS 服务器发起询问。向下传递解析请求,得到IP地址候原路返回,最后会将解析的IP地址传递到发起请求的主机。

代码

gethostbyaddr.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int i;
    struct hostent *host;
    struct sockaddr_in addr;
    if (argc != 2)
    {
        printf("Usage : %s <IP>\n", argv[0]);
        exit(1);
    }

    memset(&addr, 0, sizeof(addr));
    addr.sin_addr.s_addr = inet_addr(argv[1]);
    host = gethostbyaddr((char *)&addr.sin_addr, 4, AF_INET);
    if (!host)
        error_handling("gethost... error");

    printf("Official name: %s \n", host->h_name);
    for (i = 0; host->h_aliases[i]; i++)
        printf("Aliases %d:%s \n", i + 1, host->h_aliases[i]);
    printf("Address type: %s \n",
           (host->h_addrtype == AF_INET) ? "AF_INET" : "AF_INET6");

    for (i = 0; host->h_addr_list[i]; i++)
        printf("IP addr %d: %s \n", i + 1,
               inet_ntoa(*(struct in_addr *)host->h_addr_list[i]));

    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

gethostbyname.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int i;
    struct hostent *host;
    if (argc != 2)
    {
        printf("Usage : %s <addr>\n", argv[0]);
        exit(1);
    }
    // 把参数传递给函数,返回结构体
    host = gethostbyname(argv[1]);
    if (!host)
        error_handling("gethost... error");
    // 输出官方域名
    printf("Official name: %s \n", host->h_name);
    // Aliases 貌似是解析的 cname 域名?
    for (i = 0; host->h_aliases[i]; i++)
        printf("Aliases %d: %s \n", i + 1, host->h_aliases[i]);
    //看看是不是ipv4
    printf("Address type: %s \n",
           (host->h_addrtype == AF_INET) ? "AF_INET" : "AF_INET6");
    // 输出ip地址信息
    for (i = 0; host->h_addr_list[i]; i++)
        printf("IP addr %d: %s \n", i + 1,
               inet_ntoa(*(struct in_addr *)host->h_addr_list[i]));
    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

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

本章代码,在TCP-IP-NetworkNote中可以找到。

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

我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也很重要

9.1.1 套接字多种可选项

我们之前写得程序都是创建好套接字之后直接使用的,此时通过默认的套接字特性进行数据通信,这里列出了一些套接字可选项。

协议层选项名读取设置
SOL_SOCKETSO_SNDBUFOO
SOL_SOCKETSO_RCVBUFOO
SOL_SOCKETSO_REUSEADDROO
SOL_SOCKETSO_KEEPALIVEOO
SOL_SOCKETSO_BROADCASTOO
SOL_SOCKETSO_DONTROUTEOO
SOL_SOCKETSO_OOBINLINEOO
SOL_SOCKETSO_ERROROX
SOL_SOCKETSO_TYPEOX
IPPROTO_IPIP_TOSOO
IPPROTO_IPIP_TTLOO
IPPROTO_IPIP_MULTICAST_TTLOO
IPPROTO_IPIP_MULTICAST_LOOPOO
IPPROTO_IPIP_MULTICAST_IFOO
IPPROTO_TCPTCP_KEEPALIVEOO
IPPROTO_TCPTCP_NODELAYOO
IPPROTO_TCPTCP_MAXSEGOO

从表中可以看出,套接字可选项是分层的。

  • IPPROTO_IP 可选项是IP协议相关事项

  • IPPROTO_TCP 层可选项是 TCP 协议的相关事项

  • SOL_SOCKET 层是套接字的通用可选项。

9.1.2 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: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/

上述函数可以用来读取套接字可选项,下面的函数可以更改可选项:d

#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: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/

下面的代码可以看出 getsockopt 的使用方法。下面示例用协议层为 SOL_SOCKET 、名为 SO_TYPE 的可选项查看套接字类型(TCP 和 UDP )。

编译运行:

gcc sock_type.c -o sock_type
./sock_type

结果:

SOCK_STREAM: 1
SOCK_DGRAM: 2
Socket type one: 1
Socket type two: 2

首先创建了一个 TCP 套接字和一个 UDP 套接字。然后通过调用 getsockopt 函数来获得当前套接字的状态。

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

9.1.3 SO_SNDBUF & SO_RCVBUF

创建套接字的同时会生成 I/O 缓冲。关于 I/O 缓冲,可以去看第五章。

SO_RCVBUF 是输入缓冲大小相关可选项,SO_SNDBUF 是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 大小,也可以进行更改。通过下列示例读取创建套接字时默认的 I/O 缓冲大小。

编译运行:

gcc get_buf.c -o getbuf
./getbuf

运行结果:

Input buffer size: 87380
Output buffer size: 16384

可以看出本机的输入缓冲和输出缓冲大小。

下面的代码演示了,通过程序设置 I/O 缓冲区的大小

编译运行:

gcc get_buf.c -o setbuf
./setbuf

结果:

Input buffer size: 6144
Output buffer size: 6144

输出结果和我们预想的不是很相同,缓冲大小的设置需谨慎处理,因此不会完全按照我们的要求进行。

9.2 SO_REUSEADDR

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

在学习 SO_REUSEADDR 可选项之前,应该好好理解 Time-wait 状态。看以下代码的示例:

这是一个回声服务器的服务端代码,可以配合第四章的 echo_client.c 使用,在这个代码中,客户端通知服务器终止程序。在客户端控制台输入 Q 可以结束程序,向服务器发送 FIN 消息并经过四次握手过程。当然,输入 CTRL+C 也会向服务器传递 FIN 信息。强制终止程序时,由操作系统关闭文件套接字,此过程相当于调用 close 函数,也会向服务器发送 FIN 消息。

这样看不到是什么特殊现象,考虑以下情况:

服务器端和客户端都已经建立连接的状态下,向服务器控制台输入 CTRL+C ,强制关闭服务端

如果用这种方式终止程序,如果用同一端口号再次运行服务端,就会输出「bind() error」消息,并且无法再次运行。但是在这种情况下,再过大约 3 分钟就可以重新运行服务端。

9.2.2 Time-wait 状态

观察以下过程:

假设图中主机 A 是服务器,因为是主机 A 向 B 发送 FIN 消息,故可想象成服务器端在控制台中输入 CTRL+C 。但是问题是,套接字经过四次握手后并没有立即消除,而是要经过一段时间的 Time-wait 状态。当然,只有先断开连接的(先发送 FIN 消息的)主机才经过 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait 过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中会发生错误。

实际上,不论是服务端还是客户端,都要经过一段时间的 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意指定的,所以无需过多关注 Time-wait 状态。

那到底为什么会有 Time-wait 状态呢,在图中假设,主机 A 向主机 B 传输 ACK 消息(SEQ 5001 , ACK 7502 )后立刻消除套接字。但是最后这条 ACK 消息在传递过程中丢失,没有传递主机 B ,这时主机 B 就会试图重传。但是此时主机 A 已经是完全终止状态,因此主机 B 永远无法收到从主机 A 最后传来的 ACK 消息。基于这些问题的考虑,所以要设计 Time-wait 状态。

9.2.3 地址再分配

Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。

从图上可以看出,在主机 A 四次握手的过程中,如果最后的数据丢失,则主机 B 会认为主机 A 未能收到自己发送的 FIN 信息,因此重传。这时,收到的 FIN 消息的主机 A 将重启 Time-wait 计时器。因此,如果网络状况不理想, Time-wait 将持续。

解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。适当调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR 的默认值为 0.这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 reuseadr_eserver.c 给出,只需要把注释掉的东西解除注释即可。

optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);

此时,已经解决了上述问题。

9.3 TCP_NODELAY

9.3.1 Nagle 算法

为了防止因数据包过多而发生网络过载,Nagle 算法诞生了。它应用于 TCP 层。它是否使用会导致如图所示的差异:

图中展示了通过 Nagle 算法发送字符串 Nagle 和未使用 Nagle 算法的差别。可以得到一个结论。

只有接收到前一数据的 ACK 消息, Nagle 算法才发送下一数据。

TCP 套接字默认使用 Nagle 算法交换数据,因此最大限度的进行缓冲,直到收到 ACK 。左图也就是说一共传递 4 个数据包以传输一个字符串。从右图可以看出,发送数据包一共使用了 10 个数据包。由此可知,不使用 Nagle 算法将对网络流量产生负面影响。即使只传输一个字节的数据,其头信息都可能是几十个字节。因此,为了提高网络传输效率,必须使用 Nagle 算法。

Nagle 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快。最典型的就是「传输大文数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 Nagle 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。

所以,未准确判断数据性质时不应禁用 Nagle 算法。

9.3.2 禁用 Nagle 算法

禁用 Nagle 算法应该使用:

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

通过 TCP_NODELAY 的值来查看Nagle 算法的设置状态。

opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, &opt_len);

如果正在使用Nagle 算法,那么 opt_val 值为 0,如果禁用则为 1.

关于这个算法,可以参考这个回答:TCP连接中启用和禁用TCP_NODELAY有什么影响?

9.4 基于 Windows 的实现

暂略

9.5 习题

以下答案仅代表本人个人观点,可能不是正确答案。

  1. 下列关于 Time-wait 状态的说法错误的是?

    答:以下字体加粗的代表正确。

    1. Time-wait 状态只在服务器的套接字中发生
    2. 断开连接的四次握手过程中,先传输 FIN 消息的套接字将进入 Time-wait 状态。
    3. Time-wait 状态与断开连接的过程无关,而与请求连接过程中 SYN 消息的传输顺序有关
    4. Time-wait 状态通常并非必要,应尽可能通过更改套接字可选项来防止其发生
  2. TCP_NODELAY 可选项与 Nagle 算法有关,可通过它禁用 Nagle 算法。请问何时应考虑禁用 Nagle 算法?结合收发数据的特性给出说明。

    答:当网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快,比如说在传输大文件时。

代码

get_buf.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    int snd_buf, rcv_buf, state;
    socklen_t len;

    sock = socket(PF_INET, SOCK_STREAM, 0);
    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    len = sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    printf("Input buffer size: %d \n", rcv_buf);
    printf("Output buffer size: %d \n", snd_buf);

    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

set_buf

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    int snd_buf = 1024 * 3, rcv_buf = 1024 * 3;
    int state;
    socklen_t len;

    sock = socket(PF_INET, SOCK_STREAM, 0);
    len = sizeof(snd_buf);
    state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, sizeof(rcv_buf));
    if (state)
        error_handling("setsockopt() error");

    state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, sizeof(snd_buf));
    if (state)
        error_handling("setsockopt() error");

    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    len = sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    printf("Input buffer size: %d \n", rcv_buf);
    printf("Output buffer size: %d \n", snd_buf);

    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

reuseadr_eserver

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

#define TRUE 1
#define FALSE 0

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    char message[30];
    int option, str_len;
    socklen_t optlen, clnt_adr_sz;
    struct sockaddr_in serv_adr, clnt_adr;
    if (argc != 2)
    {
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket() error");
    /*
    optlen = sizeof(option);
    option = TRUE;
    setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
    */
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)))
        error_handling("bind() error");
    if (listen(serv_sock, 5) == -1)
        error_handling("listen error");
    clnt_adr_sz = sizeof(clnt_adr);
    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);

    while ((str_len = read(clnt_sock, message, sizeof(message))) != 0)
    {
        write(clnt_sock, message, str_len);
        write(1, message, str_len);
    }
    close(clnt_sock);
    close(serv_sock);
    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

sock_type

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int tcp_sock, udp_sock;
    int sock_type;
    socklen_t optlen;
    int state;

    optlen = sizeof(sock_type);
    tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
    udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
    printf("SOCK_STREAM: %d\n", SOCK_STREAM);
    printf("SOCK_DGRAM: %d\n", SOCK_DGRAM);

    state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen);
    if (state)
        error_handling("getsockopt() error");
    printf("Socket type one: %d \n", sock_type);

    state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen);
    if (state)
        error_handling("getsockopt() error");
    printf("Socket type two: %d \n", sock_type);
    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

为了加大服务器同时处理用户的连接,有效的利用CPU

  1. 多进程服务器: 创建多个进程提供服务
  2. 多路复用: 捆绑并统一管理IO对象提供服务
  3. 多线程: 生成与客户短等量化的线程提供服务

10.1 进程概念及引用

理解进程

进程的定义如下:占用内存空间的正在运行的程序

进程ID

在说进程创建方法之前,先要简要说明进程 ID。无论进程是如何创建的,所有的进程都会被操作系统分配一个 ID。此 ID 被称为「进程ID」,其值为大于 2 的整数。1 要分配给操作系统启动后的(用于协助操作系统)首个进程,因此用户无法得到 ID 值为 1 。接下来观察在 Linux 中运行的进程。

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 函数后的程序运行流程。如图所示:

从图中可以看出,父进程调用 fork 函数的同时复制出子进程,并分别得到 fork 函数的返回值。但复制前,父进程将全局变量 gval 增加到 11,将局部变量 lval 的值增加到 25,因此在这种状态下完成进程复制。复制完成后根据 fork 函数的返回类型区分父子进程。父进程的 lval 的值增加 1 ,但这不会影响子进程的 lval 值。同样子进程将 gval 的值增加 1 也不会影响到父进程的 gval 。因为 fork 函数调用后分成了完全不同的进程,只是二者共享同一段代码而已。接下来给出一个例子:

#include <stdio.h>
#include <unistd.h>
int gval = 10;
int main(int argc, char *argv[])
{
    pid_t pid;
    int lval = 20;
    gval++, lval += 5;
    pid = fork();
    if (pid == 0)
        gval += 2, lval += 2;
    else
        gval -= 2, lval -= 2;
    if (pid == 0)
        printf("Child Proc: [%d,%d] \n", gval, lval);
    else
        printf("Parent Proc: [%d,%d] \n", gval, lval);
    return 0;
}

10.2 进程与僵尸进程

文件操作中,关闭文件和打开文件同等重要。同样,进程销毁和进程创建也同等重要。如果未认真对待进程销毁,他们将变成僵尸进程。

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

僵尸进程是当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。如果父进程先退出 ,子进程被init接管,子进程退出后init会回收其占用的相关资源

UNIX命令ps列出的进程的状态("STAT")栏标示为 "Z"则为僵尸进程。[1]

收割僵尸进程的方法是通过kill命令手工向其父进程发送SIGCHLD信号。如果其父进程仍然拒绝收割僵尸进程,则终止父进程,使得init进程收养僵尸进程。init进程周期执行wait系统调用收割其收养的所有僵尸进程。

产生僵尸进程的原因

产生僵尸进程的原因是: 当子进程结束时,操作系统不会主动将子进程的退出状态传递给父进程 。只有父进程主动请求获取子进程的退出状态, 操作系统才会将其传递。如果父进程没有主动要求获取子进程的退出状态,操作系统将一直保存子进程的退出状态,导致子进程长时间处于僵尸进程状态。因此,为了及时回收子进程,父进程应该主动获取子进程的退出状态。

zombie.c

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    pid_t pid = fork();

    if (pid == 0)
    {
        puts("Hi, I am a child Process");
    }
    else
    {
        printf("Child Process ID: %d \n", pid);
        sleep(30);
        // 因为暂停了 30 秒,所以在这个时间内可以验证一下子进程是否为僵尸进程。
    }

    if (pid == 0)
        puts("End child proess");
    else
        puts("End parent process");
    return 0;
}

销毁僵尸 wait函数

父进程应该主动请求获取子进程的返回值以销毁子进程。使用 wait 函数来获取子进程的终止状态,通过宏 WIFEXITED 和 WEXITSTATUS 来分离和处理子进程的返回值。

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h>

int main(int argc, char *argv[]) { int status; pid_t pid = fork(); //这里的子进程将在第13行通过 return 语句终止

if (pid == 0)
{
    return 3;
}
else
{
    printf("Child PID: %d \n", pid);
    pid = fork(); //这里的子进程将在 21 行通过 exit() 函数终止
    if (pid == 0)
    {
        exit(7);
    }
    else
    {
        printf("Child PID: %d \n", pid);
        wait(&status);         //之间终止的子进程相关信息将被保存到 status 中,同时相关子进程被完全销毁
        if (WIFEXITED(status)) //通过 WIFEXITED 来验证子进程是否正常终止。如果正常终止,则调用 WEXITSTATUS 宏输出子进程返回值
            printf("Child send one: %d \n", WEXITSTATUS(status));

        wait(&status); //因为之前创建了两个进程,所以再次调用 wait 函数和宏
        if (WIFEXITED(status))
            printf("Child send two: %d \n", WEXITSTATUS(status));
        sleep(30);
    }
}
return 0;

}

销毁僵尸 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

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
    int status;
    pid_t pid = fork();
    if (pid == 0)
    {
        sleep(15); //用 sleep 推迟子进程的执行
        return 24;
    }
    else
    {
        //调用waitpid 传递参数 WNOHANG ,这样之前有没有终止的子进程则返回0
        while (!waitpid(-1, &status, WNOHANG))
        {
            sleep(3);
            puts("sleep 3 sec.");
        }
        if (WIFEXITED(status))
            printf("Child send %d \n", WEXITSTATUS(status));
    }
    return 0;

10.3 信号处理

子进程究竟何时终止?调用 waitpid 函数后要无休止的等待吗?

信号是一种软中断,它是由操作系统向进程发送的通知消息。信号的作用是通知进程发生了某种事件,如进程终止、进程收到某种输入、进程执行时间到达某一点等。信号的处理方式是通过信号处理函数来实现的。

子进程终止的识别主体是操作系统,因此,若操作系统能把子进程结束的信息告诉正忙于工作的父进程,将有助于构建更高效的程序

信号与signal函数

我们可以注册信号处理函数,当信号发生时,操作系统将调用相应的信号处理函数。信号处理函数的原型如下:

#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
/*
为了在产生信号时调用,返回之前注册的函数指针
函数名: signal
参数:int signo,void(*func)(int)
返回类型:参数类型为int型,返回 void 型函数指针
*/

这个函数比较难,我这样理解

有函数为

void func_a(int)

这个函数的是一个返回值为void,参数为int的函数.

void (*func_b)(int)

//相当于

//定义一个类型,函数指针
typedef void (*)(int) func_a_pointer;
// 定义一个指向函数的变量
func_a_pointer func_a_p = func_a;

怎么理解函数指针

再难一点,定义函数的返回值 为函数指针

void (*func_c(int))(int)

typedef void (*)(int) func_a_pointer;
// 定义一个指向函数的变量
func_a_pointer function_c(int);

再难一点,函数的返回值为函数指针,且函数的参数还有一个函数指针

void (*signal(int signo, void (*func)(int)))(int);

调用上述函数时,第一个参数为特殊情况信息,第二个参数为特殊情况下将要调用的函数的地址值(指针)。发生第一个参数代表的情况时,调用第二个参数所指的函数。下面给出可以在 signal 函数中注册的部分特殊情况和对应的函数。

  • SIGALRM:已到通过调用 alarm 函数时候产生
  • SIGINT:输入 ctrl+c
  • SIGCHLD:子进程终止
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout(int sig) //信号处理器
{
    if (sig == SIGALRM)
        puts("Time out!");
    alarm(2); //为了每隔 2 秒重复产生 SIGALRM 信号,在信号处理器中调用 alarm 函数
}
void keycontrol(int sig) //信号处理器
{
    if (sig == SIGINT)
        puts("CTRL+C pressed");
}
int main(int argc, char *argv[])
{
    int i;
    signal(SIGALRM, timeout); //注册信号及相应处理器
    signal(SIGINT, keycontrol);
    alarm(2); //预约 2 秒候发生 SIGALRM 信号

    for (i = 0; i < 3; i++)
    {
        puts("wait...");
        sleep(100);
    }
    return 0;
}

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

利用 sigaction 函数进行信号处理

还有一个函数,叫做 sigaction 函数,他类似于 signal 函数,而且可以完全代替后者,也更稳定。之所以稳定,是因为:

signal 函数在 Unix 系列的不同操作系统可能存在区别,但 sigaction 函数完全相同

实际上现在很少用 signal 函数编写程序,他只是为了保持对旧程序的兼容,下面介绍 sigaction 函数,只讲解可以替换 signal 函数的功能。

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
/*
成功时返回 0 ,失败时返回 -1
act: 对于第一个参数的信号处理函数(信号处理器)信息。
oldact: 通过此参数获取之前注册的信号处理函数指针,若不需要则传递 0
*/

声明并初始化 sigaction 结构体变量以调用上述函数,该结构体定义如下:

struct sigaction
{
    void (*sa_handler)(int);
    sigset_t sa_mask;
    int sa_flags;
};
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig)
{
    if (sig == SIGALRM)
        puts("Time out!");
    alarm(2);
}

int main(int argc, char *argv[])
{
    int i;
    struct sigaction act;
    act.sa_handler = timeout;    //保存函数指针
    sigemptyset(&act.sa_mask);   //将 sa_mask 成员的所有位初始化成0
    act.sa_flags = 0;            //sa_flags 同样初始化成 0
    sigaction(SIGALRM, &act, 0); //注册 SIGALRM 信号的处理器。

    alarm(2); //2 秒后发生 SIGALRM 信号

    for (int i = 0; i < 3; i++)
    {
        puts("wait...");
        sleep(100);
    }
    return 0;
}

10.3.4 利用信号处理技术消灭僵尸进程

下面利用子进程终止时产生 SIGCHLD 信号这一点,来用信号处理来消灭僵尸进程。看以下代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig)
{
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);
    if (WIFEXITED(status))
    {
        printf("Removed proc id: %d \n", id);             //子进程的 pid
        printf("Child send: %d \n", WEXITSTATUS(status)); //子进程的返回值
    }
}

int main(int argc, char *argv[])
{
    pid_t pid;
    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD, &act, 0);

    pid = fork();
    if (pid == 0) //子进程执行阶段
    {
        puts("Hi I'm child process");
        sleep(10);
        return 12;
    }
    else //父进程执行阶段
    {
        printf("Child proc id: %d\n", pid);
        pid = fork();
        if (pid == 0)
        {
            puts("Hi! I'm child process");
            sleep(10);
            exit(24);
        }
        else
        {
            int i;
            printf("Child proc id: %d \n", pid);
            for (i = 0; i < 5; i++)
            {
                puts("wait");
                sleep(5);
            }
        }
    }
    return 0;
}

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

之前的回声服务器每次只能同事向 1 个客户端提供服务。因此,需要扩展回声服务器,使其可以同时向多个客户端提供服务。下图是基于多进程的回声服务器的模型。

从图中可以看出,每当有客户端请求时(连接请求),回声服务器都创建子进程以提供服务。如果请求的客户端有 5 个,则将创建 5 个子进程来提供服务,为了完成这些任务,需要经过如下过程:

  • 第一阶段:回声服务器端(父进程)通过调用 accept 函数受理连接请求
  • 第二阶段:此时获取的套接字文件描述符创建并传递给子进程
  • 第三阶段:子进程利用传递来的文件描述符提供服务

Q ? 如何传递给子进程文件描述符
A 子进程会复制父进程的所有的资源,所以不需要传递

Q: 子进程会复制父进程的server_sd(套接字文件描述符)吗? 那他会收到cient的数据吗?

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30
void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_adr, clnt_adr;

    pid_t pid;
    struct sigaction act;
    socklen_t adr_sz;
    int str_len, state;
    char buf[BUF_SIZE];
    if (argc != 2)
    {
        printf("Usgae : %s <port>\n", argv[0]);
        exit(1);
    }
    act.sa_handler = read_childproc; //防止僵尸进程
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    state = sigaction(SIGCHLD, &act, 0);         //注册信号处理器,把成功的返回值给 state
    serv_sock = socket(PF_INET, SOCK_STREAM, 0); //创建服务端套接字
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1) //分配IP地址和端口号
        error_handling("bind() error");
    if (listen(serv_sock, 5) == -1) //进入等待连接请求状态
        error_handling("listen() error");

    while (1)
    {
        adr_sz = sizeof(clnt_adr);
        clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
        if (clnt_sock == -1)
            continue;
        else
            puts("new client connected...");
        pid = fork(); //此时,父子进程分别带有一个套接字
        if (pid == -1)
        {
            close(clnt_sock);
            continue;
        }
        if (pid == 0) //子进程运行区域,此部分向客户端提供回声服务
        {
            close(serv_sock); //关闭服务器套接字,因为从父进程传递到了子进程
            while ((str_len = read(clnt_sock, buf, BUFSIZ)) != 0)
                write(clnt_sock, buf, str_len);

            close(clnt_sock);
            puts("client disconnected...");
            return 0;
        }
        else
            close(clnt_sock); //通过 accept 函数创建的套接字文件描述符已经复制给子进程,因为服务器端要销毁自己拥有的
    }
    close(serv_sock);

    return 0;
}

void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}
void read_childproc(int sig)
{
    pid_t pid;
    int status;
    pid = waitpid(-1, &status, WNOHANG);
    printf("removed proc id: %d \n", pid);
}

10.4.2 实现并发服务器

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

示例中给出了通过 fork 函数复制文件描述符的过程。父进程将 2 个套接字(一个是服务端套接字另一个是客户端套接字)文件描述符复制给了子进程。

调用 fork 函数时赋值父进程的所有资源,但是套接字不是归进程所有的,而是归操作系统所有,只是进程拥有代表相应套接字的文件描述符。

如图所示,1 个套接字存在 2 个文件描述符时,只有 2 个文件描述符都终止(销毁)后,才能销毁套接字。如果维持图中的状态,即使子进程销毁了与客户端连接的套接字文件描述符,也无法销毁套接字(服务器套接字同样如此)。因此调用 fork 函数后,要将无关紧要的套接字文件描述符关掉,如图所示:

10.5 分割 TCP 的 I/O 程序

我前面已经看完了 TCPIP网络编程这本书.

  1. muduo 学习笔记 834810071/muduo_study
  2. 基于 【高级】手写C++ Muduo网络库项目-掌握高性能网络库实现原理 代码
  3. GitHub - ShangyizhouA-Tiny-Network-Library 使用 C++ 11 重构陈硕的 muduo 库,去除 boost 依赖。内部实现有 HTTP 服务器,可支持 GET 请求。
  4. 可以参考这个项目,看它是如果实现用户的注册与登录的
  5. chenshuo/documents
  6. 有ppt https://github.com/iceCream1997/muduo_server_learn
  7. S1mpleBugmuduo_cpp11 C++11版本muduo网络库 除去了对boost库的依赖 还有视频教程
  8. 结合我自己写的一些c++的笔记 https://cpphub.roj.ac.cn
  9. 当然还有我们的cppreference 手册
  10. Hansimovlinux-server 《Linux 高性能服务器编程》和《Linux多线程服务端编程:使用muduo C++网络库》的笔记和源码,以及两个轻量级服务器的项目代码 也比较有用
  11. raichen/LinuxServerCodes: Linux高性能服务器编程源码 https://github.com/raichen/LinuxServerCodes
  12. chenshuo/muduo: Event-driven network library for multi-threaded Linux server in C++11 https://github.com/chenshuo/muduo
  13. qinguoyi/TinyWebServer: Linux下C++轻量级Web服务器 https://github.com/qinguoyi/TinyWebServer
  14. linyacool/WebServer: A C++ High Performance Web Server https://github.com/linyacool/WebServer
  15. 使用工具Webbench,进行测试

其它可以参考的库

  1. Frees0u1smuduoChatServer 基于Muduo精简实现的Smuduo,在保留原有功能和特性(事件驱动的Reactor模式,非阻塞IO)的基础上,移除boost库依赖,大量使用C++11新特性.