一、网络编程,到底在干什么?

说实话,很多初学者一听到「网络编程」四个字就头大。我当年也一样。

其实说白了,网络编程就是让两台机器能互相说话。一台发数据,一台收数据。就这么简单。

那Socket是什么?你可以把它想象成一个「电话插座」。你要打电话,得先插好电话线。程序要发数据,得先创建Socket,连上对方。嗯,就是这么个东西。

核心概念:Socket是操作系统提供的一个编程接口。它封装了TCP/IP协议栈的复杂性,让你能像读写文件一样收发网络数据。

二、TCP vs UDP:一个像打电话,一个像寄明信片

这两个协议,我习惯这么跟新人讲:

  • TCP(传输控制协议):面向连接,可靠。就像打电话——你先拨号,对方接了,你们建立连接,然后你说一句他回一句,顺序不乱,丢了的包会重传。
  • UDP(用户数据报协议):无连接,不可靠。就像寄明信片——你写好地址扔邮筒里,对方能不能收到、什么时候收到、顺序对不对,你都不确定。

我在项目中遇到过一个问题:有个同事用UDP传文件,结果大文件总是缺块。他查了两天才发现,UDP根本不保证数据完整到达。后来换成TCP,一次搞定。

避坑指南:我曾经以为UDP快就什么场景都用它。后来发现,凡是要求数据完整、顺序正确的场景(比如文件传输、数据库同步),老老实实用TCP。UDP适合视频直播、游戏帧同步这类丢几个包也无所谓的场景。

三、TCP客户端与服务器:一个最简单的例子

先看服务器端。我习惯用这个模板:

// TCP服务器端核心流程
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    // 1. 创建Socket
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    // 2. 绑定地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    
    // 3. 监听
    listen(server_fd, 5);
    
    // 4. 接受连接
    int client_fd = accept(server_fd, NULL, NULL);
    
    // 5. 收发数据
    char buffer[1024] = {0};
    read(client_fd, buffer, 1024);
    write(client_fd, "Hello from server", 17);
    
    // 6. 关闭
    close(client_fd);
    close(server_fd);
    return 0;
}

客户端更简单:

// TCP客户端核心流程
int sock = socket(AF_INET, SOCK_STREAM, 0);

struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

write(sock, "Hello from client", 17);
read(sock, buffer, 1024);

close(sock);

个人经验:新手最容易忘的两件事:一是忘记调用 bind() 就直接 listen(),二是忘记用 htons() 转换端口字节序。我至少帮人排查过十几次这种问题。

四、UDP通信:简单粗暴

UDP的代码比TCP短得多。因为它不需要连接,直接发就行。

// UDP服务器
int sock = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9090);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr*)&addr, sizeof(addr));

char buffer[1024];
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
recvfrom(sock, buffer, 1024, 0, 
         (struct sockaddr*)&client_addr, &len);
sendto(sock, "pong", 4, 0, 
       (struct sockaddr*)&client_addr, len);
close(sock);

你看,没有 listen(),没有 accept(),也没有 connect()。直接 recvfromsendto 就完事了。

注意:UDP的 recvfrom 会返回发送方的地址信息。你必须保存这个地址,否则没法回数据。我见过有人用UDP做服务端,结果只收不回,查了半天才发现没保存客户端地址。

五、非阻塞I/O:别让程序卡在那

默认情况下,read()accept() 是阻塞的。什么意思?就是程序会停在那等数据,等不到就一直等。这在单线程里很要命。

解决办法:把Socket设为非阻塞。

// 设置非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

// 现在read()会立即返回
// 如果有数据,返回读取的字节数
// 如果没有数据,返回-1,errno设为EAGAIN

非阻塞的好处是:程序不会死等。你可以一边检查有没有新数据,一边干别的事。坏处是:你得自己轮询,CPU占用会高。

六、select/poll/epoll:从轮询到事件驱动

非阻塞I/O虽然不卡了,但你要同时管理几十个Socket怎么办?总不能每个都轮询一遍吧?

这时候就需要I/O多路复用。说白了就是:让内核帮你盯着这些Socket,哪个有数据了通知你。

6.1 select:老前辈

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);

struct timeval tv = {5, 0}; // 5秒超时
int ret = select(max_fd + 1, &readfds, NULL, NULL, &tv);

if (FD_ISSET(sock1, &readfds)) {
    // sock1有数据可读
}

select的问题很明显:

  • 能监控的文件描述符数量有限(通常是1024)
  • 每次调用都要把整个fd_set从用户态拷贝到内核态
  • 返回后要遍历所有fd才能知道哪些就绪了

6.2 poll:改进版

struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;

int ret = poll(fds, 2, 5000); // 5秒超时

if (fds[0].revents & POLLIN) {
    // sock1有数据
}

poll去掉了1024的限制,但性能问题还在——每次调用还是要遍历所有fd。

6.3 epoll:Linux下的王者

epoll是Linux特有的,也是目前最高效的方案。它的核心思想是:

  • 在内核中维护一个事件表,不用每次都传fd列表
  • 只返回就绪的fd,不用遍历全部
  • 支持边缘触发和水平触发两种模式
// 创建epoll实例
int epfd = epoll_create1(0);

// 添加要监控的fd
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);

// 等待事件
struct epoll_event events[10];
int n = epoll_wait(epfd, events, 10, -1);

for (int i = 0; i < n; i++) {
    if (events[i].events & EPOLLIN) {
        // 处理events[i].data.fd上的数据
    }
}

我的建议:如果你在Linux上做高并发服务器,直接用epoll。如果是跨平台,考虑用libevent或libuv这类库,它们封装了不同平台下的I/O多路复用机制。

七、一张图看懂本章知识体系

网络编程基础:知识体系 Socket编程 TCP协议 UDP协议 服务器:socket→bind→listen→accept 客户端:socket→connect→send/recv 无连接:直接sendto/recvfrom 不可靠:适合实时性要求高的场景 I/O多路复用 select(跨平台,上限1024) poll(无上限,仍需遍历) epoll(Linux高效,事件驱动)

八、总结一下

这一章我们聊了:

  • Socket是什么——就是程序间通信的「电话插座」
  • TCP和UDP的区别——可靠 vs 不可靠,连接 vs 无连接
  • TCP客户端和服务器的标准流程——记住那六个步骤
  • UDP的简单用法——没有连接,直接收发
  • 非阻塞I/O——让程序不卡顿
  • select/poll/epoll——从轮询到事件驱动的进化

说实话,网络编程入门不难,难的是把各种异常情况处理好。比如断线重连、半包粘包、超时处理……这些我们后面章节再细聊。

嗯,先到这。你先把上面的代码跑通,感受一下数据在网络上流动的感觉。有什么问题,随时翻回来看看这张图。