计算机网络编程———手写 TCP 服务器(一)搞懂网络编程核心 API

目录

[一、TCP vs UDP ------ 一句话讲清区别](#一、TCP vs UDP —— 一句话讲清区别)

[二、单进程 TCP 服务器完整拆解](#二、单进程 TCP 服务器完整拆解)

[2.1 socket + bind ------ 跟 UDP 一样](#2.1 socket + bind —— 跟 UDP 一样)

[2.2 setsockopt](#2.2 setsockopt)

[2.3 listen ------ 把电话线插上](#2.3 listen —— 把电话线插上)

[2.4 两个文件描述符 ------ 整篇文章最重要的概念](#2.4 两个文件描述符 —— 整篇文章最重要的概念)

[2.5 accept ------ 前台叫号](#2.5 accept —— 前台叫号)

[2.6 单进程版的完整流程](#2.6 单进程版的完整流程)

[2.7 ⭐ read 返回值 ------ 最要命的坑](#2.7 ⭐ read 返回值 —— 最要命的坑)

[三、客户端实现 ------ 拨号打电话](#三、客户端实现 —— 拨号打电话)

[四、源码 · 单进程 TCP 服务器完整版](#四、源码 · 单进程 TCP 服务器完整版)

makefile

Log.hpp

TcpServer.hpp

Main.cc

TcpClient.cc

五、本篇总结


最近在啃 Linux 网络编程,这篇文章是这个系列的第一篇,从最基础的单进程 TCP 服务器讲起。

这篇主要讲三件事:

  • 三个核心 API:listen、accept、read/write
  • 一个核心概念:两个文件描述符 ------ listensock_ 和 sockfd
  • 一个要命的坑:read 返回 0 为什么不处理会炸

一、TCP vs UDP ------ 一句话讲清区别

搞网络编程,第一步是搞清楚 TCP 和 UDP 到底有什么不一样。用生活类比是最容易理解的。

UDP 像寄信。 你写好一封信,扔进邮筒,对方能不能收到、什么时候收到,你不知道也不关心。你可以同时给张三、李四、王五各寄一封,完全不冲突。

TCP 像打电话。 你必须先拨号,对方接了,你们之间建立了一条专线。你在这头说,他在那头听。说完了挂电话,线路就断了。

落实到代码上,差别就在流程:

步骤 UDP 服务器 TCP 服务器
1 socket() socket()
2 bind() bind()
3 --- listen()
4 --- accept()
5 recvfrom() / sendto() read() / write()

UDP 两步半就完事了,TCP 多了 listen 和 accept。这多出来的两个 API,就是 TCP 整个复杂度的源头。

二、单进程 TCP 服务器完整拆解

下面我拆开讲每个环节,配代码,配说明,争取你看完就能自己敲出来。

2.1 socket + bind ------ 跟 UDP 一样

cpp 复制代码
listensock_ = socket(AF_INET, SOCK_STREAM, 0);

注意这里的 SOCK_STREAM。UDP 用的是 SOCK_DGRAM(数据报,一个个独立小包裹),TCP 用的是 SOCK_STREAM(流式,像水管里的水,没有边界)。

bind 的部分和 UDP 完全一样:初始化一个 struct sockaddr_in,填上 IP 和端口,传进去绑。

2.2 setsockopt

cpp 复制代码
int opt = 1;
setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));

这行代码是防坑专用

场景: 你跑着服务器,发现有个 bug,ctrl+c 停了,马上改完代码重新跑。结果报错:bind error: Address already in use

原因: 操作系统在端口释放后有一个 TIME_WAIT 状态(几十秒到几分钟不等)。这段时间内端口被认为"还在使用中",不让绑定。

解法: SO_REUSEADDR|SO_REUSEPORT 告诉内核:"哥们我知道端口还没完全释放,但我赶时间,让我先用。"

建议: 开发阶段必须加。不加的话每次重启都要等几十秒

2.3 listen ------ 把电话线插上

cpp 复制代码
listen(listensock_, backlog);  // backlog = 10

listen 做了什么?它把 socket 创建的 listensock_ 变成了监听状态,告诉操作系统:"这个 socket 可以接收客户端的连接请求了。"

backlog 是内核中连接等待队列的长度。想像一下:前台接待只能同时记住 10 个在门口排队的客人。第 11 个人来了,就得等前面有人被叫进去了才能登记。

backlog 一般设 10 左右,不用设太大。

2.4 两个文件描述符 ------ 整篇文章最重要的概念

先看一段初始化代码:

cpp 复制代码
class TcpServer {
    int listensock_;   // 由 socket() 创建
    uint16_t port_;
    std::string ip_;
};

这里有个命名上的细节。在 UDP 服务器里我们管 socket 的返回值叫 sockfd。但在 TCP 服务器里,作者把它命名为 listensock_

为什么?

因为 TCP 服务器有两个文件描述符,各司其职。

变量 谁创建的 作用 数量
listensock_ socket() 只负责监听连接请求 1 个
sockfd accept() 的返回值 负责与客户端读写通信 多个(每个连接一个)

用饭店类比:

  • listensock_ = 前台接待。看见客人来了,喊一声"服务员,3 号桌有客人"。
  • sockfd = 专属服务员。走过来:"您好,想吃点什么?"然后一对一服务。

listensock_ 不负责跟任何客人聊天,它就坐在前台监工。只有 accept 返回的 sockfd 才负责实际的读写通信。

常见错误: 新手拿到 listensock_ 直接去 read/write,发现读不到数据。那是肯定的------listensock_ 是前台接待,不是服务员。

2.5 accept ------ 前台叫号

cpp 复制代码
struct sockaddr_in client;
socklen_t len = sizeof(client);

int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);

accept 干了三件事:

  1. 从内核的连接等待队列中取出一个已经完成三次握手的连接
  2. 创建一个新的文件描述符 sockfd,专门用于和这个客户端通信
  3. 把客户端的 IP、端口等信息填到 client 结构体里

如果连接成功,accept 返回一个大于 0 的 sockfd。

如果连接失败(极少见),返回 -1。

拿到 sockfd 后,可以用 inet_ntop 把 IP 地址转成字符串,用 ntohs 把端口号转成主机字节序:

cpp 复制代码
uint16_t clientport = ntohs(client.sin_port);
char clientip[32];
inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));

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

2.6 单进程版的完整流程

cpp 复制代码
void StartServer() {
    lg(Info, "tcpserver is running...");

    for (;;) {
        // 1. 等一个客户来
        int sockfd = accept(listensock_, ...);
        if (sockfd < 0) {
            continue;  // 连接失败就继续等
        }

        // 2. 解析客户端信息(IP + 端口)
        // 3. 一对一服务
        Service(sockfd, clientip, clientport);

        // 4. 服务完关闭
        close(sockfd);
    }
}

这就是单进程/单线程的全部秘密。

来了一个客户 → accept 领到桌 → Service 开始服务 → 服务完 close → 回循环顶部继续等下一个。

在服务 A 的整个过程中,B、C、D 来了,就在门口排队等着。

2.7 ⭐ read 返回值 ------ 最要命的坑

cpp 复制代码
void Service(int sockfd, const std::string& clientip, uint16_t clientport) {
    while (true) {
        char inbuffer[4096];
        ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);

        if (n > 0) {
            // 正常收到数据
            inbuffer[n] = 0;  // 手动补 '\0'
            // 处理并回复
            write(sockfd, echo_string.c_str(), echo_string.size());

        } else if (n == 0) {
            // 🔥 客户端退出了!
            lg(Info, "%s:%d quit", clientip.c_str(), clientport);
            break;

        } else {
            // 读取出错
            lg(Warning, "read error");
            break;
        }
    }
}

为什么 n == 0 必须处理?

我用管道类比来解释。

你和朋友之间用一根水管通话。你在水管这头(服务器),他在那头(客户端)。

  • 正常情况: 朋友往水管里倒水(write),你用杯子接(read)
  • 朋友走了: 朋友把水管那头关了(关闭写端)
  • 你还举着杯子等: 水管那头堵死了,你等一整天也等不到水

这时候操作系统会怎么做?

它发现你在做一个永远读不到数据的 read ,认为你在浪费 CPU。它会直接把你的服务器进程 kill 掉

你试想一下:一个客户端正常退出,结果整个服务器被操作系统杀了。所有其他正在通信的客户端全部掉线。如果你的服务器是微信服务器,那就是几亿人同时掉线。

所以要主动处理 n == 0,break 退出,close(sockfd),告诉操作系统"我知道了"。操作系统看你主动关了,就不杀你了。


三、客户端实现 ------ 拨号打电话

客户端的逻辑比服务器简单得多:

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in server;
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));

int n = connect(sockfd, (struct sockaddr*)&server, len);

connect ------ 拨号。 就像你拿起电话(sockfd),输入对方的号码(IP+端口),按拨号键。

客户端要不要 bind?

答案是:要,但你不用手动写 bind。操作系统在 connect 的时候自动给你随机分配了一个端口号。

你打开微信的时候,不需要关心"微信用哪个端口连的服务器",对吧?那是操作系统的事。同理,我们自己写的客户端也不用手动 bind。

四、源码 · 单进程 TCP 服务器完整版

makefile

cpp 复制代码
all:tcpserver tcpclient

tcpserver:Main.cc
	g++ -o $@ $^ -std=c++11
tcpclient:TcpClient.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f tcpserver tcpclient

Log.hpp

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <ctime>
#include <cstdio>
#include <cstdarg>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define SIZE 1024

#define Info   0
#define Debug  1
#define Warning 2
#define Error  3
#define Fatal  4

#define Screen 1
#define Onefile 2
#define Classfile 3

#define LogFile "log.txt"

class Log
{
public:
    Log() { printMethod = Screen; path = "./log/"; }
    void Enable(int method) { printMethod = method; }
    ~Log() {}

    std::string levelToString(int level)
    {
        switch(level) {
            case Info: return "Info";
            case Debug: return "Debug";
            case Warning: return "Warning";
            case Error: return "Error";
            case Fatal: return "Fatal";
            default: return "";
        }
    }

    void operator()(int level, const char* format, ...)
    {
        time_t t = time(nullptr);
        struct tm* ctime = localtime(&t);
        char leftbuffer[SIZE];
        snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]",
            levelToString(level).c_str(),
            ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday,
            ctime->tm_hour, ctime->tm_min, ctime->tm_sec);

        va_list s;
        va_start(s, format);
        char rightbuffer[SIZE];
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
        va_end(s);

        char logtxt[2 * SIZE];
        snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer);
        printLog(level, logtxt);
    }

    void printLog(int level, const std::string& logtxt)
    {
        switch(printMethod) {
            case Screen:
                std::cout << logtxt << std::endl;
                break;
            case Onefile:
                printOneFile(LogFile, logtxt);
                break;
            case Classfile:
                printClassFile(level, logtxt);
                break;
            default: break;
        }
    }

    void printOneFile(const std::string& logname, const std::string& logtxt)
    {
        std::string _logname = path + logname;
        int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);
        if(fd < 0) return;
        write(fd, logtxt.c_str(), logtxt.size());
        close(fd);
    }

    void printClassFile(int level, const std::string& logtxt)
    {
        std::string filename = LogFile;
        filename += ".";
        filename += levelToString(level);
        printOneFile(filename, logtxt);
    }

private:
    int printMethod;
    std::string path;
};

Log lg;

TcpServer.hpp

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Log.hpp"

const int defaultfd = -1;
const uint16_t defaultport = 8080;
const std::string defaultip = "0.0.0.0";
const int backlog = 10;
extern Log lg;

enum
{
    UsageError = 1,
    SocketError,
    BindError,
    ListenError
};

class TcpServer
{
public:
    TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip)
        : listensock_(defaultfd), port_(port), ip_(ip)
    {}

    void InitServer()
    {
        listensock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock_ < 0) {
            lg(Fatal, "create socket error");
            exit(SocketError);
        }
        lg(Info, "create socket success, listensock_: %d", listensock_);

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

        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));
        socklen_t len = sizeof(server);

        if (bind(listensock_, (struct sockaddr *)&server, len) < 0) {
            lg(Fatal, "bind error");
            exit(BindError);
        }
        lg(Info, "bind socket success, listensock_: %d", listensock_);

        if (listen(listensock_, backlog) < 0) {
            lg(Fatal, "listen error");
            exit(ListenError);
        }
        lg(Info, "listen socket success, listensock_: %d", listensock_);
    }

    void StartServer()
    {
        lg(Info, "tcpserver is running...");
        for (;;) {
            struct sockaddr_in client;
            socklen_t len = sizeof(client);

            int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);
            if (sockfd < 0) {
                lg(Warning, "accept error");
                continue;
            }
            uint16_t clientport = ntohs(client.sin_port);
            char clientip[32];
            inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));

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

            Service(sockfd, clientip, clientport);
            close(sockfd);
        }
    }

    void Service(int sockfd, const std::string& clientip, uint16_t clientport)
    {
        while (true) {
            char inbuffer[4096];
            ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
            if (n > 0) {
                inbuffer[n] = 0;
                std::cout << "client say# " << inbuffer << std::endl;

                std::string echo_string = "tcpserver echo# ";
                echo_string += inbuffer;
                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0) {
                lg(Info, "%s:%d quit, server close sockfd: %d",
                    clientip.c_str(), clientport, sockfd);
                break;
            }
            else {
                lg(Warning, "read error, sockfd: %d", sockfd);
                break;
            }
        }
    }

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

private:
    int listensock_;
    uint16_t port_;
    std::string ip_;
};

Main.cc

cpp 复制代码
#include <iostream>
#include <memory>
#include "TcpServer.hpp"

void Usage(const std::string str)
{
    std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl;
}

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

    uint16_t port = std::stoi(argv[1]);

    std::unique_ptr<TcpServer> server(new TcpServer(port));
    server->InitServer();
    server->StartServer();

    return 0;
}

TcpClient.cc

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

void Usage(const std::string& str)
{
    std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl;
}

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

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        std::cerr << "socket create err" << std::endl;
        return 1;
    }

    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);
    inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr));
    socklen_t len = sizeof(server);

    int n = connect(sockfd, (struct sockaddr*)&server, len);
    if (n < 0) {
        std::cerr << "connect err..." << std::endl;
        return 2;
    }

    std::string message;
    char inbuffer[4096];

    while (true) {
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        n = write(sockfd, message.c_str(), message.size());
        if (n < 0) {
            std::cerr << "write err" << std::endl;
            break;
        }

        n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0) {
            inbuffer[n] = 0;
            std::cout << inbuffer << std::endl;
        } else {
            break;
        }
    }

    close(sockfd);
    return 0;
}

五、本篇总结

这篇我们做了三件事:

  1. 理解了 TCP 的三个核心 API:listen(插电话线)、accept(叫号)、read/write(对话)
  2. 分清了两个文件描述符:listensock_(前台接待)只负责监听,sockfd(专属服务员)负责通信
  3. 搞懂了一个最要命的坑:read 返回 0 必须处理,否则操作系统会杀进程

但也暴露了一个大问题:单进程版一次只能服务一个客户端,后面的排队等到死。

下一篇,我将尝试多进程、多线程、线程池等并发方案,让服务器真正能同时服务多个客户端。

相关推荐
2301_780789661 小时前
容器环境漏洞扫描:适配 K8s 架构的镜像与 Pod 安全检测方案
网络·安全·web安全·云原生·架构·kubernetes·ddos
keyipatience1 小时前
Linux进程调度与优先级机制解析
linux·运维·服务器
广州创科水利1 小时前
广州创科:以硬核科技与全栈能力,守护边坡安全监测防线
大数据·网络·人工智能
IT大白鼠1 小时前
Linux系统中应用程序安装及管理
linux·服务器
叶非花1 小时前
Ubuntu服务器性能检测工具NetData安装
linux·服务器·ubuntu
在角落发呆1 小时前
Windows 8系统下的IP转发:一台电脑如何变身网络桥梁
运维·服务器
许长安2 小时前
RingBuffer:面向网络编程的环形缓冲区实现
服务器·网络·c++·经验分享·笔记·缓存
橙淮2 小时前
- 现代网络基础设施的基石——交换机与路由器技术详解及重要性分析
计算机网络
skd89992 小时前
酒店总机永不占线解决方案-小蜜蜂多酒店系统配置说明
服务器·信息与通信