美文网首页
手写TCP服务器及其技术细节

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

作者: 欢喜树下种西瓜 | 来源:发表于2021-12-29 16:54 被阅读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);

创建一个套接字

参数说明
  • int domain

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

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

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

  • int type

设置通信的协议类型

可供选择的参数:SOCK_STREAM、SOCK_DGRAM

SOCK_STREAM - TCP

SOCK_DGRAM - UDP

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

  • int protocol

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

默认传0

返回值
  • 成功 返回新套接字所对应的文件描述符

  • 失败 返回-1,并设置errno

bind函数

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

man 2 bind

依赖头文件
函数原型

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

创建一个套接字

参数说明
  • int sockfd

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

  • const struct sockaddr *addr

绑定的连接地址结构信息

  • socklen_t addrlen

sizeof(addr) 地址结构的大小

返回值
  • 成功 返回0

  • 失败 返回-1,并设置errno

  • EACCES 被绑定的地址是受保护的地址,仅超级用户能够访问【0~1023端口】。普通用户绑定,返回EACCES错误

  • EADDRINUSE 被绑定的地址正在使用中。如,socket绑定到一个处于TIME_WAIT状态的socket地址

listen函数

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

man 2 listen

依赖头文件
函数原型

int listen(int sockfd, int backlog);

参数说明
  • int sockfd

socket()函数的返回值

  • int backlog

上限数值,最大值为128

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

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

返回值
  • 成功 返回0

  • 失败 返回-1,并设置errno

accept函数

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

man 2 accept

依赖头文件
函数原型

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

参数说明
  • int sockfd

socket()函数的返回值

  • struct sockaddr *addr

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

  • socklen_t *addrlen

传入传出参数。

socklen_t clit_addr_len = sizeof(addr);

入:addr的大小。&clit_addr_len

出:客户端addr的实际大小

返回值
  • 成功 能和客户端进行数据通信的socket对应的文件描述符

  • 失败 返回-1,并设置errno

connect函数

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

man 2 connect

依赖头文件
函数原型

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

参数说明
  • int sockfd

socket()函数的返回值

  • const struct sockaddr *addr

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

  • socklen_t addrlen

服务器的地址结构的大小

返回值
  • 成功 返回0

  • 失败 返回-1,并设置errno

  • ECONNREFUSED 目标端口不存在,连接被拒绝

  • ETIMEDOUT 连接超时。

  • EINPROGRESS 发生在非阻塞的socket调用connect,而连接有没有立即建立时。此时需要使用getsockopt去消除sockfd上的错误[可参考Linux高性能服务器编程P164]

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内核会维护两个队列:

  • 半连接队列 又称Syn队列

  • 全连接队列 又称Accept队列 半连接与全连接
  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 说白了,服务器【假设被动关闭方是服务器】在接收到了客户端的FIN后,没有执行close就会有这种情况。

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

  • LAST_ACK 在我们强制关闭处于连接状态的server进程,就会有这种情况发生。这种情况无需处理,时间到了就会消除。

总结

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

技术参考

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

相关文章

网友评论

      本文标题:手写TCP服务器及其技术细节

      本文链接:https://www.haomeiwen.com/subject/dubpqrtx.html