深入理解 TCP 套接字:Socket 编程入门教程

个人主页:chian-ocean

文章专栏-NET

深入理解 TCP 套接字:Socket 编程入门教程

前言

TCP套接字(TCP Socket)是网络编程中用于实现基于TCP协议的通信的一种机制。TCP(传输控制协议)是一个面向连接、可靠的传输层协议,保证数据的可靠性、顺序性和完整性。TCP套接字就是应用程序与TCP协议栈之间的接口,允许应用程序在网络中发送和接收数据。

TCP工作原理

建立连接(三次握手)

在数据传输之前,TCP需要建立一条可靠的连接。这个过程称为"三次握手"(Three-Way Handshake):

  • 第一步:客户端发送一个SYN(同步)包到服务器,表明希望建立连接。
  • 第二步:服务器收到SYN包后,回复一个SYN-ACK(同步-确认)包,表示同意建立连接。
  • 第三步:客户端收到SYN-ACK包后,回复一个ACK(确认)包,连接建立完成。

中断连接(四次挥手)

  • 当数据传输完成后,双方需要关闭连接。关闭过程通过"四次挥手"(Four-Way Handshake)来完成:
    1. 一方(客户端或服务器)发送FIN(结束)包,表示没有数据发送了。
    2. 对方确认FIN包,发送ACK包。
    3. 对方也发送FIN包,表示关闭连接。
    4. 初始发送方确认对方的FIN包,并发送ACK包,连接关闭。

TCP socket接口

创建套接字(socket)

c 复制代码
int socket(int domain, int type, int protocol);
参数说明:
  • domain :指定协议族,通常使用 AF_INET 表示 IPv4,AF_INET6 表示 IPv6,AF_UNIX 表示 UNIX 域套接字。
  • type :指定套接字类型,通常使用:
    • SOCK_STREAM:流式套接字,适用于 TCP 协议(面向连接)。
    • SOCK_DGRAM:数据报套接字,适用于 UDP 协议(无连接)。
  • protocol:指定协议类型,通常设为 0,表示自动选择合适的协议。

绑定地址(bind

cpp 复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数

  • sockfd: 这是一个已创建的套接字文件描述符,它是通过 socket() 系统调用返回的。
  • addr: 指向一个 sockaddr 结构体的指针,包含套接字所需绑定的地址信息。该结构体通常包括 IP 地址和端口号。
  • addrlen: addr 结构体的长度,以字节为单位。

监听连接 (listen)

cpp 复制代码
int listen(int sockfd, int backlog);

参数

  • sockfd:套接字描述符,用于标识你希望监听连接请求的套接字。

  • backlog:这个参数指定了待处理连接的队列的最大长度。也就是说,它定义了在处理接收到的连接请求之前,可以有多少个连接请求在等待队列中排队。

接受连接(accept)

cpp 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数

  • sockfd: 服务器端套接字的文件描述符(通过 socket() 创建,并通过 bind()listen() 配置)。accept() 会等待这个套接字的连接请求。

  • addr: 一个指向 sockaddr 结构的指针,用于存放连接到服务器的客户端的地址信息。可以使用 sockaddr_in(IPv4 地址)或 sockaddr_in6(IPv6 地址)结构来存储地址。

  • addrlen: 用于指定 addr 结构的大小。它在调用 accept() 后会被更新为实际存储的客户端地址长度。

数据传输readwrite

read

cpp 复制代码
ssize_t read(int fd, void *buf, size_t count);

参数:

  • fd: 文件描述符(file descriptor)。它是一个整数,标识打开的文件、管道、套接字或其他 I/O 通道。通常,0 是标准输入(stdin),1 是标准输出(stdout),2 是标准错误输出(stderr)。调用 open() 函数可以获得文件描述符。

  • buf: 指向一个缓冲区的指针,read() 会将读取的数据存储在该缓冲区中。缓冲区应该足够大,以容纳指定数量的字节。

  • count: 要读取的字节数,即期望从文件描述符 fd 中读取的最大字节数。

write

cpp 复制代码
ssize_t write(int fd, const void *buf, size_t count);

参数:

  • fd: 文件描述符(file descriptor)。标识要写入的目标文件或设备。可以是由 open() 打开的文件,也可以是标准输出(1)、标准错误(2)等文件描述符。

  • buf: 一个指向数据缓冲区的指针,write() 会将缓冲区中的数据写入到指定的文件描述符中。buf 指向的数据是要写入的原始字节数据。

  • count: 要写入的字节数,即希望将多少字节的数据从 buf 写入到文件描述符 fd

关闭连接close

cp 复制代码
int close(int fd);
  • 参数:fd 是一个整型值,表示要关闭的文件描述符。

TCPsocket编程

这个 C++ 代码实现了一个简单的 TCP 服务器,它可以通过多种方式处理客户端的请求:单进程、子进程、线程、线程池。下面我将对各个部分做详细的分析和注释。

1. 包含的头文件

cpp 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>
#include <strings.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

这些头文件包含了 C++ 中进行网络通信、进程管理、线程处理等相关操作所需要的函数和数据类型。

  • unistd.h:包含了处理系统调用的函数(如 read()write()close() 等)。
  • sys/wait.h:用于处理子进程,提供了 waitpid() 函数。
  • cstringstrings.h:用于处理字符串操作(如 memset()bzero())。
  • pthread.h:用于多线程操作,提供了线程创建、管理、同步等函数。
  • sys/socket.h:网络编程相关函数和数据类型。
  • netinet/in.harpa/inet.h:用于处理网络地址(IP 地址和端口)等。

2. 枚举类型 err

cpp 复制代码
enum err
{
    SocketErr = 1,
    BindErr,
    ListenErr,
};

这个枚举类型定义了三种错误类型:SocketErrBindErrListenErr,分别对应套接字创建、绑定和监听过程中的错误。

3. ThreadData

cpp 复制代码
class ThreadData
{
public:
    ThreadData(int sockfd, const uint16_t &clientport, const std::string clientip, TcpServer *t)
        : sockfd_(sockfd), port_(clientport), ip_(clientip), tser_(t)
    {
    }

public:
    int sockfd_;
    uint16_t port_;
    std::string ip_;
    TcpServer *tser_;
};

这个类用于保存线程执行所需的数据,包括客户端的套接字、端口、IP 地址和 TcpServer 对象的指针。它作为多线程处理中的参数传递给线程函数。

4. TcpServer

这是整个服务器的核心类,负责初始化和管理服务器的套接字、绑定、监听以及处理客户端的请求。

成员变量
cpp 复制代码
int sockfd_;
uint16_t port_;
std::string ip_;
  • sockfd_:服务器套接字的文件描述符。
  • port_:服务器监听的端口。
  • ip_:服务器监听的 IP 地址。
构造函数和析构函数
cpp 复制代码
TcpServer(uint16_t port = defaultport, std::string ip = defaultip)
    : port_(port), ip_(ip)
{
}

~TcpServer()
{
    if (sockfd_ > 0)
        close(sockfd_);
}

构造函数初始化 port_ip_,如果未提供参数,则使用默认端口 8080 和默认 IP 0.0.0.0

初始化方法 Init()
cpp 复制代码
int Init()
{
    // 创建套接字
    sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd_ < 0)
    {
        lg(FATAL, "Socket Error,errno:%d,error", errno, strerror(errno));
        return SocketErr;
    }
    lg(INFO, "Socket Success");

    int opt = 1;
    setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));

    // bind
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(port_);
    inet_aton(ip_.c_str(), &server.sin_addr);

    int bindn = bind(sockfd_, (struct sockaddr *)&server, sizeof(server));
    if (bindn < 0)
    {
        lg(FATAL, "Bind Error,errno:%d,error:%s", errno, strerror(errno));
        return BindErr;
    }
    lg(INFO, "Bind Success");

    // 监听
    int listenn = listen(sockfd_, backlog);
    if (listenn < 0)
    {
        lg(FATAL, "Listen Error,errno:%d,error:%s", errno, strerror(errno));
        return ListenErr;
    }
    lg(INFO, "Listen Success");
}
  1. 创建套接字 :使用 socket() 创建一个 TCP 套接字。
  2. 设置套接字选项 :使用 setsockopt() 设置套接字选项,允许端口复用(SO_REUSEADDR | SO_REUSEPORT)。
  3. 绑定套接字 :使用 bind() 将套接字与指定的 IP 地址和端口绑定。
  4. 监听套接字 :使用 listen() 开始监听传入的连接。
服务器运行方法 Run()
cpp 复制代码
void Run()
{
    lg(INFO, "TCP running...");
    ThreadPool<Task>::GetInstance()->start();
    lg(INFO,"ThreadPool Start...");
    for (;;)
    {
        struct sockaddr_in client;
        bzero(&client, sizeof(client));
        socklen_t clientlen = sizeof(client);
        int sockfd = accept(sockfd_, (struct sockaddr *)&client, &clientlen);
        if (sockfd < 0)
        {
            continue;
        }

        uint16_t clientport = ntohs(client.sin_port);
        char clientip1[32];
        inet_ntop(AF_INET, &(client.sin_addr), clientip1, sizeof(client));
        std::string clientip = clientip1;

        lg(INFO, "get a new link..., sockfd: %d, client ip: %s, client port: %d", sockfd, clientip.c_str(), clientport);

        // 选择处理方式
        ThreadPolls(sockfd, clientport, clientip);
    }
}
  1. 等待客户端连接 :使用 accept() 等待并接收客户端的连接。
  2. 获取客户端信息:获取客户端的 IP 和端口。
  3. 处理客户端请求:将客户端信息传递给线程池进行处理。
处理客户端请求方法 Service()
cpp 复制代码
void Service(int sockfd, const uint16_t &clientport, const std::string clientip)
{
    char buffer[4096];
    while (true)
    {
        ssize_t n = read(sockfd, &buffer, sizeof(buffer));
        if (n > 0)
        {
            buffer[n] = 0;
            std::cout << "Client say# " << buffer << std::endl;
            std::string echo = "TcpServer ehco# ";
            echo += buffer;

            write(sockfd, echo.c_str(), echo.size());
        }
        else if (n == 0)
        {
            lg(INFO, "Server Quit..");
            break;
        }
        else if (n < 0)
        {
            lg(WARNING, "read error");
            break;
        }
    }
}

这个方法会持续读取客户端发送的数据,并将接收到的数据回传给客户端,直到客户端断开连接。

5. 多进程、多线程和线程池处理

单进程 :直接调用 Service() 方法处理请求。
cpp 复制代码
// 单进程
void SingleProcess(int sockfd, const uint16_t &clientport, const std::string clientip)
{
    Service(sockfd, clientport, clientip);
    close(sockfd);
}
多进程 :使用 fork() 创建子进程来处理每个客户端请求。
cpp 复制代码
// 多进程
void MultiProcess(int sockfd, const uint16_t &clientport, const std::string clientip)
{
    pid_t id = fork();
    if (id == 0)
    {
        lg(INFO, "Child Process Create...");
        close(sockfd_);
        if (fork() > 0)
            exit(0); // 进程孤儿
        Service(sockfd, clientport, clientip);
        close(sockfd);
        exit(0);
    }
    // father
    close(sockfd);
    int n = waitpid(id, nullptr, 0);
    if (n > 0)
        lg(INFO, "Child Process Quit...");
}
多线程 :使用 pthread_create() 创建线程来处理请求。
cpp 复制代码
// 多线程
void MultiThreads(int sockfd, const uint16_t &clientport, const std::string& clientip)
{
    pthread_t tid;
    ThreadData *td = new ThreadData(sockfd, clientport, clientip, this);
    pthread_create(&tid, nullptr, Routine, td);
}
static void *Routine(void *argv)
{
    pthread_detach(pthread_self()); // 线程分离

    ThreadData *td = static_cast<ThreadData *>(argv);

    td->tser_->Service(td->sockfd_, td->port_, td->ip_);

    delete td;

    return nullptr;
}
线程池 :通过线程池(ThreadPool)将客户端的处理任务添加到队列中,线程池会负责处理这些任务。
  • 这块需要接入线程池的API
cpp 复制代码
//线程池
void ThreadPolls(int sockfd, uint16_t &clientport,std::string clientip)
{
    Task t(sockfd, clientport, clientip);
    ThreadPool<Task>::GetInstance()->push(t);
}

Linux客户端

  • 实现一个翻译功能
cpp 复制代码
#include <iostream> // 引入输入输出流库
#include <string> // 引入字符串处理库
#include <unistd.h> // 引入系统调用函数库,提供read/write等函数
#include <cstring> // 引入C字符串处理函数
#include <strings.h> // 引入处理字符串的辅助函数
#include <sys/socket.h> // 引入套接字相关函数
#include <sys/types.h> // 引入定义套接字数据类型
#include <netinet/in.h> // 引入网络协议族和结构体
#include <arpa/inet.h> // 引入IP地址转换函数

#include "log.hpp" // 引入日志处理头文件

// 打印程序的使用说明
void Usage(std::string proc)
{
    std::cout << "\n\rUsage: " << proc << " port/ip[1024+]\n" << std::endl;
}

int main(int argc, char *argv[])
{
    // 检查命令行参数个数
    if (argc != 3)
    {
        Usage(argv[0]); // 参数不正确时输出使用说明
        return 1;
    }

    // 将命令行传入的端口号转为 uint16_t 类型,IP 地址存为字符串
    uint16_t port = std::stoi(argv[2]);
    std::string ip = argv[1];

    // 创建 sockaddr_in 结构体存储客户端的网络地址
    struct sockaddr_in client;
    unsigned int len = sizeof(client);
    bzero(&client, len); // 清空结构体,避免脏数据

    // 将 IP 地址从字符串转换为二进制格式并赋值给 client.sin_addr
    inet_pton(AF_INET, ip.c_str(), &(client.sin_addr));
    client.sin_family = AF_INET; // 设置地址族为 IPv4
    client.sin_port = htons(port); // 将端口号转换为网络字节序

    // 创建一个 TCP 套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        lg(FATAL, "Socket error. errno:%d ,error", errno, strerror(errno)); // 套接字创建失败时记录日志
        return 1;
    }

    // 连接到指定的服务器
    if (connect(sockfd, (struct sockaddr *)&client, sizeof(client)) < 0)
    {
        lg(FATAL, "connect error. errno:%d ,error", errno, strerror(errno)); // 连接失败时记录日志
        return 1;
    }
    
    // 进入一个无限循环,不断接收用户输入并发送
    while (true)
    {
        std::string message;

        // 提示用户输入消息
        std::cout << "Please Enter:  ";
        getline(std::cin, message); // 获取用户输入

        // 发送消息给服务器
        int n = write(sockfd, message.c_str(), message.size());

        // 创建一个缓冲区接收服务器的回应
        char buffer[4096];
        ssize_t s = read(sockfd, &buffer, sizeof(buffer)); // 从服务器读取数据
        if (s > 0)
        {
            buffer[s] = 0; // 在接收到的内容末尾加上字符串结束符
            std::cout << buffer << std::endl; // 输出服务器回应的内容
        }
        
    }

    return 0; // 程序正常结束
}

1. 命令行参数处理

  • 程序接收两个命令行参数:服务器的 IP 地址和端口号。如果参数不正确,程序会输出使用说明并退出。

2. 创建套接字并连接服务器

  • 使用 socket() 创建一个 TCP 套接字。
  • 使用 connect() 函数连接到指定的服务器(通过 IP 地址和端口)。

3. 消息发送与接收

  • 程序进入一个无限循环,不断等待用户输入消息。
  • 用户输入的消息通过 write() 发送到服务器。
  • 使用 read() 从服务器读取回显数据,并输出到屏幕。

4. 退出机制

  • 该程序没有显式的退出机制,只有当程序被外部中断(如按 Ctrl+C)时才会停止。

测试

Windows客户端

cpp 复制代码
#include <iostream>
#include <string>
#include <winsock2.h>  // 包含 WinSock2 库,提供网络编程相关的函数
#pragma comment(lib, "ws2_32.lib")  // 链接 Winsock 库

#pragma warning(disable: 4996)  // 禁用警告:编译器提醒使用不推荐的函数(如strcpy)

using namespace std;

int main() {

    // 设置控制台字符编码为 UTF-8
    SetConsoleOutputCP(CP_UTF8);

    // 初始化 WinSock 库
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);  // 初始化 Winsock 库,要求 Winsock 版本为 2.2
    if (result != 0) {
        std::cerr << "WinSock初始化失败!错误代码:" << result << std::endl;
        return 1;  // 如果初始化失败,则返回1
    }

    // 创建一个 TCP 套接字
    SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);  // 创建一个 SOCK_STREAM 类型的套接字(TCP协议)
    if (clientSocket == INVALID_SOCKET) {
        std::cerr << "创建套接字失败!错误代码:" << WSAGetLastError() << std::endl;
        WSACleanup();  // 如果创建套接字失败,清理 Winsock 资源
        return 1;  // 返回1表示程序失败
    }

    // 设置服务器的 IP 地址和端口
    sockaddr_in serverAddr;
    serverAddr.sin_family = AF_INET;  // 地址族:IPv4
    serverAddr.sin_port = htons(8888);  // 服务器端口,使用 htons 转换成网络字节序
    serverAddr.sin_addr.s_addr = inet_addr("119.3.219.187");  // 服务器 IP 地址

    // 连接到服务器
    result = connect(clientSocket, (sockaddr*)&serverAddr, sizeof(serverAddr));  // 发起连接请求
    if (result == SOCKET_ERROR) {
        std::cerr << "连接失败!错误代码:" << WSAGetLastError() << std::endl;
        closesocket(clientSocket);  // 关闭套接字
        WSACleanup();  // 清理 Winsock 资源
        return 1;  // 返回1表示程序失败
    }

    // 客户端主循环,持续发送消息和接收服务器响应
    while (true) {
        std::string message;
        getline(cin, message);  // 从标准输入获取消息
        result = send(clientSocket, message.c_str(), message.size(), 0);  // 发送消息到服务器
        if (result == SOCKET_ERROR) {
            std::cerr << "发送数据失败!错误代码:" << WSAGetLastError() << std::endl;
            closesocket(clientSocket);  // 发送失败,关闭套接字
            WSACleanup();  // 清理 Winsock 资源
            return 1;  // 返回1表示程序失败
        }

        char buffer[512];  // 用于接收服务器的响应数据
        result = recv(clientSocket, buffer, sizeof(buffer), 0);  // 接收服务器响应
        if (result > 0) {  // 如果接收成功
            buffer[result] = '\0';  // 确保数据结尾符(以便正确显示字符串)
            std::cout << buffer << std::endl;  // 输出服务器响应
        }
        else if (result == 0) {  // 如果返回 0,表示服务器关闭了连接
            std::cout << "服务器已关闭连接" << std::endl;
            break;  // 跳出循环,结束客户端与服务器的通信
        }
        else {  // 如果接收失败,输出错误信息
            std::cerr << "接收数据失败!错误代码:" << WSAGetLastError() << std::endl;
        }
    }

    // 关闭套接字并清理 WinSock 资源
    closesocket(clientSocket);
    WSACleanup();  // 清理 Winsock 资源

    return 0;  // 正常结束
}

代码工作流程:

  1. 初始化 WinSock 库: 使用 WSAStartup 初始化 WinSock 库,指定要求使用的 WinSock 版本。
  2. 创建套接字: 创建一个 TCP 套接字,通过 socket 函数实现。
  3. 连接到服务器: 通过 connect 函数发起与指定 IP 地址和端口的连接。
  4. 发送和接收消息: 程序进入循环,等待用户输入消息,发送给服务器,并接收服务器的响应。
  5. 关闭连接: 当服务器关闭连接或发生错误时,客户端退出循环,关闭套接字并清理资源。

测试:

Linux源码

Windows源码

相关推荐
sztomarch29 分钟前
Router-Routing
linux·运维·服务器·前端·网络
茉莉玫瑰花茶32 分钟前
传输层协议TCP(下)
服务器·网络·tcp/ip
achene_ql42 分钟前
手写muduo网络库(七):深入剖析 Acceptor 类
linux·服务器·开发语言·网络·c++
爱分享的程序员1 小时前
前端面试专栏-基础篇:5. HTTP/2 协议深度解析
网络·网络协议·http
rit84324991 小时前
Odoo 17 在线聊天报错 “Couldn‘t bind the websocket...“ 的解决方案
网络·websocket·网络协议
houhuan1281 小时前
楼宇自控新方向:电力载波技术——低成本、高兼容性的智能未来
大数据·运维·网络·人工智能·3d
1nullptr1 小时前
【持续更新】linux网络编程试题
linux·服务器·网络
桜見1 小时前
ubuntu22.04有线网络无法连接,图标也没了
网络
数据与人工智能律师2 小时前
数据淘金时代:公开爬取如何避开法律雷区?
网络·人工智能·算法·云计算·区块链
canyuemanyue2 小时前
WSL2 默认使用 NAT 网络
网络