第二讲:网络编程基础(一)——Socket编程入门,TCP与UDP的选择

好,咱们正式开始聊网络编程。

说实话,很多新手一上来就被各种网络模型、IO复用给吓住了。其实没必要。网络编程的根基,就是Socket。你把这个搞明白了,后面什么epoll、reactor,都是锦上添花。

这一讲,我带你把Socket编程的底裤扒干净。顺便聊聊那个老生常谈的问题:TCP还是UDP?

2.1 什么是Socket?

Socket,中文叫“套接字”。名字挺拗口,但本质很简单——它就是操作系统提供的一个编程接口,让你能在网络上收发数据。

你可以把它想象成一个“电话插座”。你这边插上电话机(绑定IP和端口),拨号(连接),然后就能通话(收发数据)了。挂断(关闭连接),就这么回事。

我当年刚学的时候,总觉得Socket是个很玄乎的东西。后来写了个简单的聊天程序,才恍然大悟:哦,原来就是读写一个文件描述符啊!

小提示: 在Linux下,一切皆文件。Socket也不例外。它本质上就是一个文件描述符(fd)。你往里面写数据,就是发送;从里面读数据,就是接收。

2.2 TCP Socket编程——面向连接的可靠传输

TCP,全称传输控制协议。它的核心特点就四个字:可靠、有序

你发出去的数据,TCP保证能按顺序到达对方手里。如果丢了,它会重传。如果乱了,它会排序。你只管把数据扔给它,剩下的它帮你搞定。

嗯,听起来很美好。但代价是什么?性能开销大,有连接建立和断开的成本。

2.2.1 TCP Socket的典型流程

TCP Socket编程,服务端和客户端的流程是不一样的。我画个简单的步骤图给你看:

  1. 服务端: socket() → bind() → listen() → accept() → recv()/send() → close()
  2. 客户端: socket() → connect() → send()/recv() → close()

你看,服务端多了一个bind和listen。为什么?因为服务端需要明确告诉操作系统:“我在这台机器的这个端口上等着,谁来我都接。”

客户端不需要。客户端是主动发起连接的,操作系统会自动给它分配一个临时端口。

2.2.2 一个最简单的TCP回射服务器

光说不练假把式。咱们直接上代码。这是一个最简单的TCP回射服务器——客户端发什么,服务端原样返回什么。

// 服务端代码(C++风格,但核心是系统调用)
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 1. 创建Socket
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket");
        return -1;
    }

    // 2. 绑定地址和端口
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
    server_addr.sin_port = htons(8888);               // 端口8888

    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(listen_fd);
        return -1;
    }

    // 3. 开始监听
    if (listen(listen_fd, 10) == -1) {  // 10是backlog,表示最大等待连接数
        perror("listen");
        close(listen_fd);
        return -1;
    }

    printf("服务器已启动,监听端口8888...\n");

    // 4. 接受客户端连接
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
    if (conn_fd == -1) {
        perror("accept");
        close(listen_fd);
        return -1;
    }

    printf("客户端已连接:%s:%d\n", 
           inet_ntoa(client_addr.sin_addr), 
           ntohs(client_addr.sin_port));

    // 5. 收发数据
    char buffer[1024];
    while (1) {
        memset(buffer, 0, sizeof(buffer));
        int n = recv(conn_fd, buffer, sizeof(buffer) - 1, 0);
        if (n <= 0) {
            printf("客户端断开连接\n");
            break;
        }
        printf("收到:%s", buffer);
        send(conn_fd, buffer, n, 0);  // 原样返回
    }

    // 6. 关闭连接
    close(conn_fd);
    close(listen_fd);
    return 0;
}
重点提醒: 注意看htonl和htons这两个函数。它们负责把主机字节序转换成网络字节序。不同CPU的字节序可能不同(大端/小端),网络传输统一用大端。我见过有人忘了这个,结果端口号对不上,排查了半天。

2.3 UDP Socket编程——无连接的不可靠传输

UDP,用户数据报协议。它和TCP完全相反——不可靠、无序、无连接

你发一个数据包出去,它可能到,也可能不到。到了也可能顺序是乱的。UDP不负责这些,它只管发。

听起来很坑?其实不然。UDP的优势是:速度快、开销小、支持广播和多播

2.3.1 UDP Socket的流程

UDP的流程比TCP简单得多:

  1. 服务端: socket() → bind() → recvfrom()/sendto() → close()
  2. 客户端: socket() → sendto()/recvfrom() → close()

看到了吗?UDP不需要listen和accept,也不需要connect。它就像寄信——你写好地址(IP和端口),扔进邮筒(sendto),就行了。对方能不能收到,看运气。

2.3.2 一个最简单的UDP回射服务器

// UDP回射服务器
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>

int main() {
    // 1. 创建Socket(注意是SOCK_DGRAM)
    int sock_fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock_fd == -1) {
        perror("socket");
        return -1;
    }

    // 2. 绑定地址
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(8888);

    if (bind(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(sock_fd);
        return -1;
    }

    printf("UDP服务器已启动,监听端口8888...\n");

    // 3. 收发数据(不需要accept)
    char buffer[1024];
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    while (1) {
        memset(buffer, 0, sizeof(buffer));
        int n = recvfrom(sock_fd, buffer, sizeof(buffer) - 1, 0,
                         (struct sockaddr*)&client_addr, &client_len);
        if (n < 0) {
            perror("recvfrom");
            break;
        }
        printf("收到来自 %s:%d 的消息:%s", 
               inet_ntoa(client_addr.sin_addr),
               ntohs(client_addr.sin_port),
               buffer);
        
        // 原样返回
        sendto(sock_fd, buffer, n, 0,
               (struct sockaddr*)&client_addr, client_len);
    }

    close(sock_fd);
    return 0;
}
注意: UDP的recvfrom和sendto每次都要指定对方的地址。因为UDP是无连接的,操作系统不知道你要跟谁通信,你得每次都告诉它。这一点和TCP完全不同。

2.4 TCP vs UDP:到底该怎么选?

这是每个游戏后端开发者都会面临的问题。我直接给你一个决策指南:

场景 推荐协议 原因
登录、注册、支付 TCP 必须可靠,丢一个包都不行
聊天消息 TCP 需要保证消息顺序和完整性
实时位置同步 UDP 可以容忍丢包,但延迟要低
帧同步(格斗/赛车) UDP 每帧数据独立,丢了就丢了
状态同步(MMO) TCP或UDP+可靠层 看具体实现,各有优劣
广播/组播 UDP TCP不支持广播

我个人习惯是:核心逻辑用TCP,高频状态用UDP

举个例子。我在做一款MOBA游戏时,英雄的移动、技能释放这些高频操作,用的就是UDP。为什么?因为TCP一旦丢包重传,整个操作序列就卡住了,玩家会感觉“技能放不出来”。而UDP丢了就丢了,下一帧补上就行。

但玩家的金币、等级、装备这些关键数据,必须用TCP。这些数据错了,游戏就没法玩了。

避坑指南: 我曾经在一个项目中,全部用UDP实现了一个“可靠UDP”层。结果发现,自己实现的可靠性比TCP差远了,还引入了各种bug。后来我学乖了:不要重复造轮子,除非你真的很懂。大多数情况下,TCP已经足够好了。

2.5 几个容易踩的坑

最后,分享几个我当年踩过的坑。你记住了,能省不少时间。

  • 端口被占用: 服务端关闭后,端口会进入TIME_WAIT状态,短时间内无法重用。可以用setsockopt设置SO_REUSEADDR来解决。
  • TCP粘包: TCP是流式协议,没有消息边界。你发两次数据,对方可能一次就收完了。解决办法:自己定义消息格式,比如“长度+内容”。
  • UDP丢包: UDP不保证送达。如果你的应用场景需要可靠性,要么自己实现ACK和重传,要么直接用TCP。
  • 缓冲区溢出: recv和recvfrom的缓冲区要足够大。UDP一个包最大65507字节,但建议不要超过1472(以太网MTU 1500 - IP头20 - UDP头8)。

好了,这一讲就到这里。Socket编程其实不难,难的是把它用好、用对。下一讲,我们会深入聊聊TCP的底层机制——三次握手、四次挥手、滑动窗口。这些东西搞明白了,你才算真正入了网络编程的门。

记住:纸上得来终觉浅,绝知此事要躬行。把上面的代码敲一遍,跑起来,比看十遍文章都管用。