手写TCP服务器及其技术细节

2021-12-29  本文已影响0人  欢喜树下种西瓜

前言

此文章以记录个人学习tcp serve的点滴心得

  1. 了解C语言socket编程

  2. 能够独立编写tcp server代码

  3. 了解一定的tcp-server的细节

若能对读者有以上三个方面有所帮助,这将是我的荣幸

网络套接字

所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。简而言之,socket是网络上两个程序双向通讯连接的端点。 Linux最显著的特色——万物皆文件,在网络编程这里也体现得淋漓尽致。

既然万物皆文件,那么socket也可以理解为文件,针对Socket这个文件,可以使用打开(open)、读写(write、read)和关闭(close)来操作,自然会有对应的socket函数对其进行操作(可看最基本的网络套接字函数这一章)

image

最基本的网络套接字函数

若对基本的套接字函数了解较多,可直接跳过这块

socket函数

man 2 socket

依赖头文件
函数原型

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

创建一个套接字

参数说明

domain指定使用何种的地址类型

可供选择的参数:AF_INET【IPv4】、AF_INET6【IPv6】、AF-UNIX【UNIX本地域协议族】

这些AF_*都定义在 bits/socket.h头文件中

设置通信的协议类型

可供选择的参数:SOCK_STREAM、SOCK_DGRAM

SOCK_STREAM - TCP

SOCK_DGRAM - UDP

可添加的参数: SOCK_NONBLOCK【新创建的socket是非阻塞的】、SOCK_CLOEXEC【fork出的子进程关闭该socket】

参数protocol用来指定socket所使用的传输协议编号

默认传0

返回值

bind函数

给socket绑定一个地址结构【IP+端口号】

man 2 bind

依赖头文件
函数原型

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

创建一个套接字

参数说明

此为socket()函数的返回值。

绑定的连接地址结构信息

sizeof(addr) 地址结构的大小

返回值

listen函数

设置同时与服务器建立连接的上限数【同时进行3次握手的客户端数量】

man 2 listen

依赖头文件
函数原型

int listen(int sockfd, int backlog);

参数说明

socket()函数的返回值

上限数值,最大值为128

高性能服务器编程P76 backlog表示完全连接状态(ESTABLISHED)的socket的上限【处于listen而未被accpet之间的状态的数量】

个人理解为,允许的全连接队列数量上限

返回值

accept函数

阻塞等待客户端建立连接。成功的话,返回一个与客户端成功连接的socket文件描述符;失败

man 2 accept

依赖头文件
函数原型

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

参数说明

socket()函数的返回值

传出参数。成功与服务器成功建立连接的那个客户端的地址结构【ip+端口】。accept()返回的这是和这个读写对应的sockfd

传入传出参数。

socklen_t clit_addr_len = sizeof(addr);

入:addr的大小。&clit_addr_len

出:客户端addr的实际大小

返回值

connect函数

使用现有的socket与服务器建立连接【客户端使用】

man 2 connect

依赖头文件
函数原型

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

参数说明

socket()函数的返回值

传入参数。写服务器的地址结构:

服务器的地址结构的大小

返回值

demo代码

这里只展示最基本的socket编程代码,后续的将会在gitee上放入。

最基本的tcp-server
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 9999

int main() {
    // SOCK_STREAM 对应的是TCP流
    // SOCK_DGRAM 对应的是UDP流
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    assert(listenfd > 0);

    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind error!");
        exit(-1);
    }
#if 1
    // 注释掉listen
    if (listen(listenfd, 5) == -1) {
        perror("listen error!");
        exit(-1);
    }
#endif
    // clinet_addr用以接收新客户端的连接信息
    struct sockaddr_in client_addr;
    memset(&client_addr, 0, sizeof(client_addr));
    socklen_t client_addr_len = sizeof(client_addr);
    // 拿到的clientfd是新建立连接的socket
    int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addr_len);
    assert(clientfd > 0);
    // 打印新客户的连接信息
    printf("new connect [%s:%d], clientfd: [%d]\n",
           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), clientfd);
    char buf[1024] = {0};
    memset(buf, 0, sizeof(buf));
    int nread = recv(clientfd, buf, sizeof(buf), 0);
    printf("we got the msg: %s, %d\n", buf, nread);
    // 模拟阻塞,此时客户端recv会阻塞住
    while (1) {
    }
    // send(clientfd, buf, 1024, 0);
    close(clientfd);
    close(listenfd);
    return 0;
}
最基本的tcp-client
#include <arpa/inet.h>
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

#define PORT 9999
int main() {
    int sfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serve_addr;
    memset(&serve_addr, 0, sizeof(serve_addr));
    inet_pton(AF_INET, "192.168.1.100", &serve_addr.sin_addr);
    serve_addr.sin_family = AF_INET;
    serve_addr.sin_port = htons(PORT);
    socklen_t serv_addr_len = sizeof(serve_addr);

    int ret = connect(sfd, (struct sockaddr *)&serve_addr, serv_addr_len);
    if (ret == -1) {
        perror("Connect error!\n");
        printf("%s\n", strerror(errno));
    }
    assert(ret != -1);
    send(sfd, "Hello", strlen("Hello"), 0);
#if 1
    // 测试TCP三次握手建立在server端的bind还是listen函数
    while (1) {
    }
#endif
    char buf[1024] = {0};
    printf("recv code: %ld\n", recv(sfd, buf, 1024, 0));
    close(sfd);
}

TCP服务器的一些细节问题

三次握手

三次握手

这里以上面贴出的最基本的tcp-server代码作为研究对象,说明以下几个问题:

  1. 具体的wireshark流量报告

  2. 三次握手是建立在server端的accept还是bind函数

  3. listen函数中的backlog参数对应着什么内容

具体的wireshark流量报告
wireshark抓包图

PS:这里的192.168.1.101主机为client端,马赛克的为server端。

具体哪个函数完成TCP的三次握手

我们将server端的代码修改为:

int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd > 0);

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1) {
 perror("bind error!");
 exit(-1);
}
if (listen(listenfd, 5) == -1) {
 perror("listen error!");
 exit(-1);
}
查询wireshark是发现正常进行了三次握手的。 image

也就是说,TCP连接是在listen函数后实现的。

如果我们把listen函数注释掉,只保留了bind函数,通过wireshark查询会发现Server端拒绝了连接: server拒接请求

listen函数到底做了什么?

TCP的连接,本质上是linux内核进行处理。我们程序员(用户)调用的listen函数,是给TCP全连接队列设置处理上限。

半连接队列与全连接队列

TCP三次握手时,linux内核会维护两个队列:

  1. 客户端执行connect函数后,客户端发送SYN包

  2. 服务器接收到客户端发送的数据包,并将相关信息放入半连接队列中(SYN队列)并返回SYN+ACK包给客户端

  3. 客户端收到服务器的SYN+ACK包,返回ACK包给服务器;此时,服务器在接收到客户端的ACK包后,就会从半连接队列里将数据取出来放到全连接队列(Accept队列)。

listen函数里的backlog参数就是限制全连接队列容量的【以系统与listen设置的backlog最小值为准】 通过限制全连接数量可以控制服务请求数量,如果TCP全连接队列过少会导致全队列溢出,后续的请求会被抛弃,出现服务请求数量上不去的问题。

如上图[server拒绝请求 一图]中,我们将listen函数注释掉后,服务器丢弃该请求[直接返回RST给客户端,以废弃掉这个握手过程与连接],客户端也会提示"connection reset by peer"错误

四次挥手

TCP是全双工的【有读端和写端】

  1. 客户端发起断开连接,用户(程序员)调用close函数后,客户端内核就会自动处理(加上FIN标志位等)。

  2. 服务器内核接收到客户端的FIN后,返回给用户空包(recv函数返回的是0),同时内核自动返回ACK给客户端。

  3. 服务器的用户(程序员)再调用close函数后,服务器的内核会自动处理(加上FIN标志位发送消息给客户端)。【服务端的写端关闭】【客户端的读端关闭】

  4. 客户端的内核在接收到FIN后,自动返回ACK给服务端。至此,四次挥手完成,客户端与服务器彻底断联。【客户端的写端关闭】【服务端的读端关闭】 四次挥手

这里面有几个关键的状态:

通常出现大量的CLOSE_WAIT是业务部分耗时过长/业务逻辑出现问题,导致服务器没有及时close掉socket连接。 可以将业务部分转至消息队列处理或者优化业务代码逻辑

总结

Tcp Server是非常容易实现的,但是里面蕴含的细节非常多。这篇文章还有许多地方需要补充,后续在实际开发中面对到都会继续补充。 若对您有所帮助,实属我之大幸。

技术参考

1.视频技术参考 https://ke.qq.com/course/417774?flowToken=1041378

上一篇下一篇

猜你喜欢

热点阅读