【Linux】揭开套接字编程的神秘面纱(下)

作者:@阿亮joy.
专栏:《学会Linux》
座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
在这里插入图片描述

目录

    • 前言
    • echo服务器
      • 单进程版
      • 多进程版
      • 多线程版
      • 线程池版
    • 深入剖析地址转换函数
    • TCP协议通讯流程
    • 总结

前言

在揭开套接字编程神秘面纱(上)中,我们已经学习到了套接字编程的相关基础知识以及编写了基于 UDP 协议的 echo 服务器、指令服务器和简易版的公共聊天室等,那么我们现在就来学习基于 TCP 协议的套接字编程。

echo服务器

单进程版

TcpServer.hpp

#pragma once

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "Log.hpp"

#define SIZE 1024

static void Service(int sock, const std::string& clientIP, uint16_t clientPort)
{
    // Echo Server
    char buffer[SIZE];
    while(true)
    {
        ssize_t s = read(sock, buffer, sizeof(buffer) - 1);
        if(s > 0)
        {
            buffer[s - 1] = '\0';
            std::cout << clientIP << " : " << clientPort << "#" << buffer << std::endl;
        }
        else if(s == 0) // 对端关闭连接
        {
            logMessage(NORMAL, "%s:%d quit, me too!", clientIP.c_str(), clientPort);
            break;
        }
        else
        {
            logMessage(FATAL, "Read Fail, Errno:%d, Strerror:%s", errno, strerror(errno));
            break;
        }
        // 将消息发回去
        write(sock, buffer, strlen(buffer));
    }
}

class TcpServer
{
private:
    const static int backlog = 20; // 全队列长度
public:
    TcpServer(uint16_t port, std::string ip = "")
        : _port(port)
        , _ip(ip)
        , _listenSock(-1)
    {}

    void InitServer()
    {
        // 1. 创建套接字:SOCK_STREAM面向字节流
        _listenSock = socket(AF_INET, SOCK_STREAM, 0);
        if(_listenSock < 0)
        {
            logMessage(FATAL, "Create Socket Fail! Errno:%d Strerror:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "Create Socket Success! _sock:%d", _listenSock);

        // 2. 绑定套接字
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
        local.sin_port = htons(_port);
        // 绑定套接字失败
        if(bind(_listenSock, (struct sockaddr*)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "Bind Socket Fail! Errno:%d Strerror:%s", errno, strerror(errno));
            exit(3);
        }

        // 3. 因为TCP是面向连接的,那么正式进行网络通信时,先需要建立连接
        if(listen(_listenSock, backlog) < 0)
        {
            logMessage(FATAL, "Listen Socket Fail! Errno:%d Strerrno:%s", errno, strerror(errno));
            exit(4);
        }
        logMessage(NORMAL, "Init Server Success!");
    }

    void StartServer()
    {
        while(true)
        {
            // 4. 获取连接
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            // accept函数的返回值是文件描述符,它用于后续的网络通信
            // 而_sock只用于获取新连接,并不用于后续的网络通信
            // 注:accept是阻塞等待新连接的到来
            int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
            if(serviceSock < 0)
            {
                logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
                continue;
            }
            // 获取连接成功,开始进行网络通信
            uint16_t clientPort = ntohs(src.sin_port);
            std::string clientIP = inet_ntoa(src.sin_addr);
            logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
            // 单进程循环版
            Service(serviceSock, clientIP, clientPort);
            close(serviceSock);
        }
    }

    ~TcpServer()
    {
        if(_listenSock >= 0)
            close(_listenSock);
    }

private:
    uint16_t _port;
    std::string _ip;
    int _listenSock;
};

注:日志组件的代码在揭开套接字编程的神秘面纱(上)一文中可以找到!

listen 函数的详细介绍

int listen(int sockfd, int backlog);
  • listen 是一个用于在服务器端等待客户端连接的函数。
  • listen 函数的第一个参数 sockfd 是监听套接字(listen socket),监听套接字是一种特殊类型的套接字,用于接受连接请求,并在连接建立时创建新的套接字。监听套接字通常用于服务器程序中,服务器在特定的端口上等待客户端的连接请求。当客户端请求连接时,监听套接字会接受连接请求,并创建一个新的套接字来与客户端进行通信
  • listen 函数的第二个参数 backlog 表示服务器在接受连接请求时,最多能够排队等待的连接数。在某些情况下,服务器可能会同时收到多个客户端的连接请求,如果服务器无法及时处理这些请求,这些请求就会在队列中等待处理,此时 backlog 参数就派上用场了。
  • 具体来说,backlog 参数的值表示服务器等待连接请求的队列长度,当队列已满时,服务器会拒绝新的连接请求。如果该值过小,服务器可能无法处理所有的连接请求;如果该值过大,则会占用过多的系统资源,导致服务器性能下降。一般来说,backlog 参数的取值应该根据服务器的处理能力和网络环境等因素进行合理的设置,以确保服务器可以及时处理连接请求,同时又不会占用过多的系统资源。

查看网络状态

【Linux】揭开套接字编程的神秘面纱(下)_第1张图片

Telnet 协议

Telnet 是一种用于在互联网上进行远程登录的协议,也是一种基于文本的协议,其运行在 TCP /I P 协议上。telnet命令是一种用于测试网络连接性和调试网络问题的工具,同时也可以用于远程登录到另一个计算机。

在使用 telnet 命令时,可以通过以下语法来调用它:

telnet [选项] [主机名或IP地址] [端口号]

其中,主机名或 IP 地址指定要连接的远程主机名或 IP 地址,端口号指定要连接的远程端口。如果未指定端口号,则默认使用 23 端口(Telnet 服务端口)。

【Linux】揭开套接字编程的神秘面纱(下)_第2张图片

使用 telnet 命令时,可以先输入 telnet 命令并指定要连接的主机名和端口号。如果连接成功,将会看到远程主机上的欢迎信息。按下组合键 Ctrl + ],再按下回车键,此时就看输入信息发送给服务端了。在 telnet 会话中,可以通过输入命令来与远程主机进行交互,就像在本地终端上一样。要退出 telnet 会话,需要按下组合键 Ctrl + ],然后输入 quit 命令。

需要注意的是,由于 Telnet 协议是明文传输,不提供任何加密和安全机制,因此使用telnet进行远程登录并不安全。为了保护数据的机密性和完整性,应该使用更加安全的协议,例如 SSH(Secure Shell)协议。

【Linux】揭开套接字编程的神秘面纱(下)_第3张图片
【Linux】揭开套接字编程的神秘面纱(下)_第4张图片
单进程版的 echo 服务器的细节

因为现在服务器是单进程的,所以当有两个连接来了时,服务器只能处理一个连接,并且要当该连接关闭才能处理下一个链接!

【Linux】揭开套接字编程的神秘面纱(下)_第5张图片

TcpServer.cc

#include "TcpServer.hpp"
#include 

static void Usage(std::string proc)
{
    std::cout << "\nUsage: " << proc << " Port" << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t port = atoi(argv[1]);
    std::unique_ptr<TcpServer> ptr(new TcpServer(port));
    ptr->InitServer();
    ptr->StartServer();

    return 0;
}

多进程版

因为单进程版的 echo 服务器只能处理一个客户端的链接,那么我们就将其改写成多进程版。

多进程版的 TCP 服务器中,主进程(父进程)会接收客户端的连接请求,然后创建一个新的子进程来处理连接。在子进程中,会执行 TCP 通信的相关操作。当子进程处理完请求前,需要关闭不需要的文件描述符,以释放资源并确保安全性。

在多进程环境下,每个进程都有自己的文件描述符表,如果不关闭不需要的文件描述符,则可能会导致资源泄漏和安全问题。例如,一个子进程可能会在某个文件上持续进行读取操作,但是在父进程中却没有这个需要读取的文件,如果不关闭该文件描述符,则会造成资源浪费和潜在的安全问题。

因此,在多进程版的 TCP 服务器中,父进程和子进程需要各自关闭自己不需要的文件描述符,以确保每个进程都能够释放资源并保证程序的安全性。这样做可以提高程序的效率和稳定性,避免出现资源竞争和其他问题。

void StartServer()
{
    // 主动忽略SIGCHLD信号,子进程退出的时候会自动释放自己的僵尸状态
    signal(SIGCHLD, SIG_IGN); 
    while(true)
    {
        // 4. 获取连接
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        // accept函数的返回值是文件描述符,它用于后续的网络通信
        // 而_sock只用于获取新连接,并不用于后续的网络通信
        // 注:accept是阻塞等待新连接的到来
        int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
        if(serviceSock < 0)
        {
            logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
            continue;
        }
        // 获取连接成功,开始进行网络通信
        uint16_t clientPort = ntohs(src.sin_port);
        std::string clientIP = inet_ntoa(src.sin_addr);
        logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
        pid_t id = fork();
        assert(id != -1);
        (void)id;
        if(id == 0)
        {
            // 子进程会继承父进程文件描述符表
            // 子进程不需要关心监听套接字
            close(_listenSock);
            Service(serviceSock, clientIP, clientPort);
            exit(0);
        }
        // 父进程不需要关系用于提供服务的套接字
        close(serviceSock);
    }
}

【Linux】揭开套接字编程的神秘面纱(下)_第6张图片

为什么多个子进程所用于通信的套接字(文件描述符)都是相等的呢?因为父进程会关闭自己所不需要的文件描述符,这个不需要的文件描述符就是 4,所以每次用于网络通信的文件描述符都是 4。

多进程的改进版

void StartServer()
{
    while(true)
    {
        // 4. 获取连接
        struct sockaddr_in src;
        socklen_t len = sizeof(src);

        int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
        if(serviceSock < 0)
        {
            logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
            continue;
        }
        // 获取连接成功,开始进行网络通信
        uint16_t clientPort = ntohs(src.sin_port);
        std::string clientIP = inet_ntoa(src.sin_addr);
        logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);

        pid_t id = fork();
        if(id == 0)
        {
            // 子进程
            close(_listenSock);
            if(fork() > 0) exit(0); // 子进程本身立即退出
            // 因为子进程退出了,那么孙子进程就会北城孤儿进程被1号进程
            // 领养,让操作系统自动释放孙子进程的僵尸状态
            Service(serviceSock, clientIP, clientPort);
            exit(0);
        }
        // 父进程
        close(serviceSock);
        waitpid(id, nullptr, 0); // 此时的waitpid不会阻塞太久
    }
}

TcpClient.cc

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 


static void Usage(std::string proc)
{
    std::cout << "\nUsage:" << proc << "serverIP serverPort" << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    uint16_t sock = socket(AF_INET, SOCK_STREAM, 0);
    if(sock < 0)
    {
        std::cerr << "Create Socket Fail!" << std::endl;
        exit(2);
    }

    std::string serverIP = argv[1];
    uint16_t serverPort = atoi(argv[2]);
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    server.sin_addr.s_addr = inet_addr(serverIP.c_str());

    if(connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0)
    {
        std::cerr << "Connet Fail!" << std::endl;
        exit(3);
    }

    std::cout << "Connet Success!" << std::endl;
    while(true)
    {
        std::string message;
        std::cout << "Please Enter Your Message: ";
        std::getline(std::cin, message);
        send(sock, message.c_str(), message.size(), 0);
        char buffer[1024];
        ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << "server# " << buffer << std::endl;
        }
        else if(s == 0)
            break;
        else
            break;
    }
    close(sock);

    return 0;
}

TCP 客户端端口号的绑定问题

当客户端程序调用 connect 系统调用时,内核会为客户端分配一个临时的、未绑定的端口号,并将其绑定到客户端套接字描述符对应的网络地址上。需要注意的是,如果客户端希望绑定特定的端口号,可以在调用 connect 之前使用 bind 系统调用来指定端口号。但是,这种情况比较少见,通常情况下客户端会使用动态分配的端口号。

【Linux】揭开套接字编程的神秘面纱(下)_第7张图片

send、recv 和 sendto、recvfrom 的区别

【Linux】揭开套接字编程的神秘面纱(下)_第8张图片
【Linux】揭开套接字编程的神秘面纱(下)_第9张图片

多线程版

多线程版需要注意的细节:

  • 创建出来的线程和主线程都不能够关闭自己不需要文件描述符,因为文件描述符是被所有线程共享的。如果关闭了文件描述符,将会影响到其他线程的执行。
  • 多线程应该进行线程分离,这样主线程就不需要关心多线程的退出状态了。
class ThreadData
{
public:
    uint16_t _port;
    std::string _ip;
    int _sock;
};

class TcpServer
{
    static void* threadRoutine(void* args)
    {
        // 线程分离,主线程不行关心其退出状态
        pthread_detach(pthread_self());
        ThreadData* td = (ThreadData*)args;
        Service(td->_sock, td->_ip, td->_port);
        delete td;
        return nullptr;
    }
public:
    void StartServer()
    {
        while(true)
        {
            // 4. 获取连接
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            // accept函数的返回值是文件描述符,它用于后续的网络通信
            // 而_sock只用于获取新连接,并不用于后续的网络通信
            // 注:accept是阻塞等待新连接的到来
            int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
            if(serviceSock < 0)
            {
                logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
                continue;
            }
            // 获取连接成功,开始进行网络通信
            uint16_t clientPort = ntohs(src.sin_port);
            std::string clientIP = inet_ntoa(src.sin_addr);
            logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
            // 子线程不能关闭文件描述符,因为多线程场景下文件描述符是公用的
            ThreadData* td = new ThreadData();
            td->_sock = serviceSock;
            td->_port = clientPort;
            td->_ip = clientIP;
            pthread_t tid;
            pthread_create(&tid, nullptr, threadRoutine, td);
        }
    }
};

【Linux】揭开套接字编程的神秘面纱(下)_第10张图片

线程池版

本篇博客使用的线程池相较于线程池的实现,有略微的改动。主要改动如下:类型的重命名,将 Thread.hpp 中的typedefvoid*(*func_t)(void*)改成 typedefvoid*(*Func_t)(void*),以避免与 Task.hpp 中的 func_t 产生命名冲突。还有改动就是将任务类的 Excute 函数改成了 operator(),并给任务类多加了一下成员变量。

任务类

#pragma once

#include 
#include 
#include 

using func_t = std::function<void(int, const std::string&, const uint16_t&, const std::string&)>;

// 任务类
class Task
{
public:
    Task() = default;

    Task(int sock, const std::string& ip, uint16_t port, func_t func)
        : _sock(sock)
        , _ip(ip)
        , _port(port)
        , _func(func)
    {}

    void operator()(std::string& name)
    {
        _func(_sock, _ip, _port, name);
    }

private:
    int _sock;
    std::string _ip;
    uint16_t _port;
    func_t _func;
};
class TcpServer
{
private:
    const static int backlog = 20; // 全队列长度
public:
    TcpServer(uint16_t port, std::string ip = "")
        : _port(port)
        , _ip(ip)
        , _listenSock(-1)
        , _ptr(ThreadPool<Task>::getThreadPool())
    {}

	// ...

    void StartServer()
    {
        _ptr->Run();
        while(true)
        {
            // 4. 获取连接
            struct sockaddr_in src;
            socklen_t len = sizeof(src);
            // accept函数的返回值是文件描述符,它用于后续的网络通信
            // 而_sock只用于获取新连接,并不用于后续的网络通信
            // 注:accept是阻塞等待新连接的到来
            int serviceSock = accept(_listenSock, (struct sockaddr*)&src, &len);
            if(serviceSock < 0)
            {
                logMessage(ERROR, "Accept FAIL, Errno:%d Strerrno:%s", errno, strerror(errno));
                continue;
            }
            // 获取连接成功,开始进行网络通信
            uint16_t clientPort = ntohs(src.sin_port);
            std::string clientIP = inet_ntoa(src.sin_addr);
            logMessage(NORMAL, "Link Success! serviceSock:%d clientIP:%s clientPort:%d", serviceSock, clientIP.c_str(), clientPort);
            Task t(serviceSock, clientIP, clientPort, Service);
            _ptr->Push(t);
        }
    }
	// ...
private:
	// ...
    std::unique_ptr<ThreadPool<Task>> _ptr;
};

【Linux】揭开套接字编程的神秘面纱(下)_第11张图片

关于线程池版的 echo 服务器,需要注意一下几点:

  • 服务器最多同时在线 g_thread_num 人(注:g_thread_num在 threadPool.hpp)中定义,因为服务器和每个客户端建立的都是长连接,而不是短连接。
  • 如果想将线程池版的 echo 服务器改成其他服务,如:在线字典、大小写转换等,只需要修改构建任务时所传的回调函数即可。

深入剖析地址转换函数

在 Linux 操作系统中,有一些用于进行地址转换的函数,主要用于处理网络通信中的地址格式转换。以下是一些常用的 Linux 网络通信中的地址转换函数:

  • inet_aton 和 inet_addr: 这两个函数用于将点分十进制表示的 IPv4 地址转换为网络字节序的二进制表示。inet_aton 将 IPv4 地址转换为 struct in_addr 类型的结构体,而 inet_addr 则将 IPv4 地址转换为 32 位无符号整数。
    【Linux】揭开套接字编程的神秘面纱(下)_第12张图片

  • inet_ntoa:这个函数用于将网络字节序的二进制表示的 IPv4 地址转换为点分十进制表示的字符串形式。

  • inet_pton 和 inet_ntop: 这两个函数用于进行 IPv4 和 IPv6 地址之间的二进制表示和文本表示之间的转换。inet_pton 将 IPv4 或 IPv6 地址的字符串表示转换为对应的二进制表示,存储在指定的结构体中。inet_ntop 则将二进制表示的 IPv4 或 IPv6 地址转换为对应的文本表示。

inet_aton 和 inet_ntoa 函数的使用

#include 
#include 
#include 
#include 

int main()
{
    struct sockaddr_in addr;
    inet_aton("127.0.0.1", &addr.sin_addr);
    uint32_t* ptr = (uint32_t*)&addr.sin_addr;
    printf("addr: %x\n", *ptr);
    printf("addr_str: %s\n", inet_ntoa(addr.sin_addr));

    return 0;
}

inet_pton 和 inet_ntop 函数的使用

#include 
#include 

int main() 
{
    char ip_addr[] = "127.0.0.1";
    struct in_addr addr;

    // 将字符串形式的IPv4地址转换为二进制形式,并存储到addr中
    if (inet_pton(AF_INET, ip_addr, &addr) <= 0) 
    {
        printf("Invalid IP address\n");
        return -1;
    }

    // 输出二进制形式的IP地址
    printf("Binary IP address: 0x%x\n", addr.s_addr);

    return 0;
}
#include 
#include 

int main() 
{
    struct sockaddr_in sa;
    char buffer[INET_ADDRSTRLEN];

    // 设置IPv4地址
    sa.sin_family = AF_INET;
    sa.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

    // 将二进制格式IP地址转换为字符串格式
    const char *ip = inet_ntop(AF_INET, &(sa.sin_addr), buffer, INET_ADDRSTRLEN);

    printf("IP地址:%s\n", buffer);

    return 0;
}

关于 inet_ntoa 函数

inet_ntoa 这个函数返回了一个 char*,很显然是这个函数自己在内部为我们申请了一块内存来保存 IP 的结果. 那么是否需要调用者手动释放呢?

在这里插入图片描述

man 手册上说,inet_ntoa 函数是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放。那么问题来了,如果我们调用多次这个函数,会有什么样的效果呢?参见如下代码:

#include 
#include 
#include 

int main()
{
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;
    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr = 0xffffffff;
    char* ptr1 = inet_ntoa(addr1.sin_addr);
    char* ptr2 = inet_ntoa(addr2.sin_addr);
    printf("ptr1:%s  ptr2:%s\n", ptr1, ptr2);

    return 0;
}

【Linux】揭开套接字编程的神秘面纱(下)_第13张图片
因为 inet_ntoa 把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果。

  • 很明显,inet_ntoa 不是一个线程安全的函数,如果有多个线程调用 inet_ntoa 函数,可能会出现异常情况,但是在 centos7 上测试, 并没有出现问题, 可能内部的实现加了互斥锁。
  • 在多线程环境下,推荐使用 inet_ntop,这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题。

测试代码:

#include 
#include 
#include 
#include 
#include 
#include 

void* Func1(void* p) 
{
    struct sockaddr_in* addr = (struct sockaddr_in*)p;
    while (1) 
    {
        char* ptr = inet_ntoa(addr->sin_addr);
        printf("addr1: %s\n", ptr);
        sleep(1);
    }
    return NULL;
}

void* Func2(void* p) 
{
    struct sockaddr_in* addr = (struct sockaddr_in*)p;
    while (1) 
    {
        char* ptr = inet_ntoa(addr->sin_addr);
        printf("addr2: %s\n", ptr);
        sleep(1);
    }
    return NULL;
}

int main() 
{
    pthread_t tid1 = 0;
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;
    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr = 0xffffffff;
    pthread_create(&tid1, NULL, Func1, &addr1);
    pthread_t tid2 = 0;
    pthread_create(&tid2, NULL, Func2, &addr2);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);
    return 0;
}

TCP协议通讯流程

【Linux】揭开套接字编程的神秘面纱(下)_第14张图片

TCP(Transmission Control Protocol)协议是一种面向连接的、可靠的、基于字节流的传输协议,其通讯流程如下:

服务器初始化:

  • 调用 socket 函数,创建文件描述符
  • 调用 bind 函数,将文件描述符和 IP / Port 绑定在一起;如果这个端口号已经被其他进程占用了,就会绑定失败。
  • 调用 listen 函数声明,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的 accept 做好准备
  • 调用 accept 函数并阻塞,等待客户端连接的到来

建立连接

  • 客户端调用 socket 函数,创建文件描述符
  • 客户端调用 connect 函数,向服务端发起连接请求
  • connect 会向服务端发送 SYN 包,并阻塞等待服务器应答(第一次握手)
  • 服务端收到 SYN 包后,回复 ACK+SYN 包,表示已经接收到客户端的请求,并且同意建立连接(第二次握手)
  • 客户端收到 SYN-ACK 包后会从 connect 函数返回,同时应答一个ACK包,表示连接已经建立成功(第三次握手)

数据传输:

  • 建立连接后,TCP 协议提供全双工的通信服务;所谓全双工的意思是,在同一条连接中,同一时刻通信双方可以同时写数据;相对的概念叫做半双工,同一条连接在同一时刻,只能由一方来写数据。
  • 服务器从 accept 函数返回后立刻调用 read 函数,读 socket 就像读管道一样,如果没有数据到达就阻塞等待。这时客户端调用 write 函数发送请求给服务器,服务器收到后从 read 函数返回,对客户端的请求进行处理。在此期间,客户端调用 read 函数阻塞等待服务器的应答;服务器调用 write 函数将处理结果发回给客户端,再次调用 read 函数阻塞等待下一条请求;客户端收到应答后从 read 函数返回,发送下一条请求,如此循环下去。

断开连接:

  • 如果客户端没有更多的请求了,就调用 close 函数关闭连接,客户端会向服务器发送 FIN 包,请求释放连接(第一次挥手)
  • 此时服务器收到 FIN 包后,会回应一个 ACK 包,表示已经接收到客户端的释放请求;同时 read 函数会返回 0,表示客户端关闭了连接(第二次挥手)
  • read 返回之后,服务器就知道客户端关闭了连接,也调用 close 函数关闭连接,这个时候服务器会向客户端发送
    一个 FIN 包,请求释放连接(第三次挥手)
  • 客户端收到 FIN 包,再返回一个 ACK 包给服务器,表示已经接收到服务端的释放请求,连接已经成功关闭(第四次挥手)

建立连接的过程通常称为三次握手,断开连接的过程通常称为四次挥手。

在学习 socket API 时,需要注意应用程序和TCP协议层是如何交互的:

  • 应用程序调用某个 socket 函数时,TCP 协议层完成什么动作,比如调用 connect 函数会发出 SYN 包
  • 应用程序如何知道 TCP 协议层的状态变化,比如从某个阻塞的 socket 函数返回,就表明 TCP 协议收到了某些
    包,再比如 read 函数返回 0 就表明收到了 FIN 包

TCP和UDP的对比

TCP 和 UDP 都是在网络通信中常用的传输协议,它们之间的主要区别如下:

  • 连接性:TCP 是面向连接的协议,UDP 是无连接的协议。TCP 在通信之前需要先建立连接,而 UDP 则直接发送数据,不需要先建立连接。

  • 可靠性:TCP 是可靠的协议,UDP 是不可靠的协议。TCP 通过三次握手、四次挥手等机制,保证数据的可靠性,数据传输过程中可以进行校验、重传等操作,可以保证数据的完整性。而 UDP 没有这些机制,如果发送的数据丢失或者损坏,就会导致数据的丢失或损坏。

  • 速度:UDP 比 TCP 更快。由于 TCP 需要建立连接和保证可靠性,因此在数据传输过程中需要进行许多额外的操作,导致速度较慢。而 UDP 直接发送数据,没有这些额外的操作,因此速度更快。

  • TCP 是面向字节流的,UDP 是面向数据报的。面向数据包就是对方发一次,我就接收一次;而面向字节流是对方发多次,我一次就全部接收。

总结

本篇博客基于 TCP 协议编写了单进程版、多进程版、多线程版、线程池版的 echo 服务器、深入剖析地址转换函数以及 TCP 协议的通讯流程等。以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家啦!❣️

你可能感兴趣的:(学会Linux,服务器,运维,Linux,TCP协议)