计算机网络(3) -- socket网络通信
Socket 是操作系统提供给应用程序的一套编程接口(API),是应用层 ↔ 传输层之间的桥梁,封装了 TCP/IP 协议复杂的内核细节,程序员不用直接操作网卡、IP 报文、TCP 首部,调用函数就能实现网络通信。本质:把网络通信抽象成文件 IO,Linux 下一切 Socket 都是文件,可用read / write 收发数据。Socket主要是实现跨主机进程之间的数据通信,一台电脑上不同进程、不同电脑上的软件,都可以依靠 Socket 收发数据。两大常用套接字类型有:流式套接字SOCK_STREAM(TCP)和 数据报套接字 SOCK_DGRAM(UDP)。
一、tcp服务器端与客户端编程流程
TCP 提供的是面向连接的、可靠的、字节流服务。 TCP 的服务器端和客户端编程流程如下:
socket()方法是用来创建一个套接字,有了套接字就可以通过网络进行数据的收发。这也是为什么进行网络通信的程序首先要创建一个套接字。创建套接字时要指定使用的服务类型,使用 TCP 协议选择流式服务(SOCK_STREAM)。
bind()方法是用来指定套接字使用的IP 地址和端口。IP 地址就是自己主机的地址,如果主机没有接入网络,测试程序时可以使用回环地址“127.0.0.1”。端口是一个 16 位的整型值,一般 0-1024 为知名端口,如 HTTP 使用的 80 号端口。这类端口一般用户不能随便使用。其次, 1024-4096 为保留端口, 用户一般也不使用。 4096 以上为临时端口,用户可以使用。在Linux 上, 1024 以内的端口号,只有 root 用户可以使用。在传参数的话需要先将IP地址和端口号转为网络字节序,创建出sockaddr_in结构体,在传参时进行强转。
listen()方法是用来创建监听队列。监听队列有两种,一个是存放未完成三次握手的连接(半连接队列),一种是存放已完成三次握手的连接(全连接队列)。 listen()第二个参数就是指定已完成三次握手队列的长度。
accept()处理存放在 listen 创建的已完成三次握手的队列中的连接。新建一个套接字描述符返回,专门用来和对应客户端read/recv/write/send传输数据。如果该队列为空,则 accept 阻塞。
connect()方法一般由客户端程序执行,需要指定连接的服务器端的 IP 地址和端口。该方法执行后,会进行三次握手, 建立连接。
send()方法用来向 TCP 连接的对端发送数据。 send()执行成功,只能说明将数据成功写入到发送端的发送缓冲区中,并不能说明数据已经发送到了对端。 send()的返回值为实际写入到发送缓冲区中的数据长度。
recv()方法用来接收 TCP 连接的对端发送来的数据。 recv()从本端的接收缓冲区中读取数据,如果接收缓冲区中没有数据,则 recv()方法会阻塞。返回值是实际读到的字节数,如果recv()返回值为 0, 说明对方已经关闭了 TCP 连接。下图演示了一方给另一方发送数据的过程,因为TCP协议是双工通信的,接收端同样也可以作为发送端,它们都有自己独立的发送缓冲区和接收缓冲区。
close()方法用来关闭 TCP 连接。此时,会进行四次挥手。
大端字节序和小端字节序
当数据超过 1 个字节时(比如 int 4 字节、short 2 字节), 计算机在内存里存放这些字节的顺序,就叫字节序。
大端字节序:高字节存低地址,低字节存高地址,像我们人类写字的顺序,从左到右,高位在前。
小端字节序:低字节存低地址,高字节存高地址。
0x1234 大端:高字节存低地址,低字节存高地址 低地址-->高地址 0x12 34 小端:低字节存低地址,高字节存高地址 低地址-->高地址 0x34 12可通过一个union联合体来观察是否电脑的字节序。
#include <stdio.h> #include <stdlib.h> union Data { int i; char c; }; //判断电脑是大端还是小端 int main() { union Data data; data.i = 0x12345678; if (data.c == 0x78){ //高地址存高位,所以是小端 printf("小端\n"); }else if (data.c == 0x12){ //高地址存低位,所以是大端 printf("大端\n"); } return 0; }通过编译运行发现,我的电脑配置是小端,一般像电脑,手机这些大都是小端字节序,而我们网络传输必须是大端,所以我们需要通过一些系统调用来转换字节序。
Linux 系统提供如下4 个函数来完成主机字节序和网络字节序之间的转换:
#include <netinet/in.h> l 管 IP、s 管端口;发网络 h→n,收数据 n→h //前两个传IP地址 uint32_t htonl(uint32_t hostlong); // 长整型的主机字节序转网络字节序 参数:主机序 32 位整型 IP 返回:网络大端序IP,赋值给sin_addr.s_addr uint32_t ntohl(uint32_t netlong); // 长整型的网络字节序转主机字节序 参数:从网络拿到的大端 32 位 IP 返回:本机主机序IP整型 //后两个传端口号 uint16_t htons(uint16_t hostshort); // 短整形的主机字节序转网络字节序 参数:主机十进制端口(8080、6666) 返回:网络大端端口,赋值给赋值 sin_port uint16_t ntohs(uint16_t netshort); // 短整型的网络字节序转主机字节序 //参数:报文里收到的网络序端口 //返回:正常十进制端口,用于打印IP 地址转换函数
IP 地址转换函数,通常,我们习惯用点分十进制字符串表示 IPV4 地址,但编程中我们需要先把它们转化为整数方能使用,下面函数可用于点分十进制字符串表示的 IPV4 地址和网络字节序整数表示的 IPV4 地址之间的转换:
#include <arpa/inet.h> in_addr_t inet_addr(const char *cp); //字符串表示的 IPV4 地址转化为网络字节序 char* inet_ntoa(struct in_addr in); // IPV4 地址的网络字节序转化为字符串表示套接字结构体
套接字的特性由3个属性确定,它们是:域(domain),类型(type)和协议(protocol);套接字用地址作为它的名字,地址的格式随域(又被称为协议族,protocol family)的不同而不同.每个协议族又可以使用一个或多个地址族来定义地址格式.
通用socket地址结构,socket 网络编程接口中表示 socket 地址的是结构体 sockaddr,其定义如下:
#include <bits/socket.h> struct sockaddr { sa_family_t sa_family;//协议族 char sa_data[14];//数据,没有给出IP地址,就是给了这么一块儿空间,起了一个占位的作用. };sa_family 成员是地址族类型(sa_family_t) 的变量。地址族类型通常与协议族类型对应。常见的协议族和对应的地址族如下图所示:
但是一般我们不使用通用的sockaddr结构体进行传参,我们一般使用TCP/IP 协议族中的sockaddr_in 和 sockaddr_in6两个专用socket 地址结构体,它们分别用于IPV4 和 IPV6,在传参数的时候必须强转为sockaddr * 类型。
//sin_family: 地址族 AF_INET //sin_port: 端口号,需要用网络字节序表示 //sin_addr: IPV4 地址结构: s_addr 以网络字节序表示 IPV4 地址 struct in_addr { u_int32_t s_addr;//无符号的32位的整型,存放IP地址; }; //tcp协议族 struct sockaddr_in { sa_family_t sin_family;//地址族,就是sin_family: 地址族 AF_INET u_int16_t sin_port;//端口,16位的端口 struct in_addr sin_addr;//一个结构体,只有一个成员,是无符号的32位的整型, 存放IP地址;(IPV4的地址就是32位) //其实后面还有占位的,只是我们不用它,所以就没有写; }; //tcp协议族就主要有三个:地址族,端口号,IP地址 //IP协议族 struct in6_addr { unsigned char sa_addr[16]; // IPV6 地址,要用网络字节序表示 }; struct sockaddr_in6 { sa_family_t sin6_family; // 地址族: AF_INET6 u_inet16_t sin6_port; // 端口号:用网络字节序表示 u_int32_t sin6_flowinfo; // 流信息,应设置为 0 struct in6_addr sin6_addr; // IPV6 地址结构体 u_int32_t sin6_scope_id; // scope ID,尚处于试验阶段 };前面了解了一些基础的转化接口和结构体,下面为socket网络编程接口:
#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol); //socket()创建套接字,成功返回套接字的文件描述符,失败返回-1 //domain: 设置套接字的协议簇, AF_UNIX AF_INET AF_INET6 //type: 设置套接字的服务类型 SOCK_STREAM SOCK_DGRAM // protocol: 一般设置为 0,表示使用默认协议 int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); //bind()将 sockfd 与一个 socket 地址绑定,成功返回 0,失败返回-1 //sockfd 是网络套接字描述符,(命名套接字,就是上面的函数的返回值作为了我们的参数sockfd) //addr 是地址结构(通过初始化sockaddr_in 之后强转成sockaddr * 类型) //addrlen 是 socket 地址的长度 int listen(int sockfd, int backlog); //listen()创建一个监听队列以存储待处理的客户连接,成功返回 0,失败返回-1 //sockfd 是被监听的 socket 套接字 //backlog 表示处于完全连接状态的 socket 的上限 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //accept()从 listen 监听队列中接收一个连接,成功返回一个新的连接 socket, //该 socket 唯一地标识了被接收的这个连接,失败返回-1 //sockfd 是执行过 listen 系统调用的监听 socket //addr 参数用来获取被接受连接的远端 socket 地址 //addrlen 指定该 socket 地址的长度 int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen); //connect()客户端需要通过此系统调用来主动与服务器建立连接, //成功返回 0,失败返回-1 //sockfd 参数是由 socket()返回的一个 socket。 //serv_addr 是服务器监听的 socket 地址 //addrlen 则指定这个地址的长度 int close(int sockfd); //close()关闭一个连接,实际上就是关闭该连接对应的 socket ssize_t recv(int sockfd, void *buff, size_t len, int flags); ssize_t send(int sockfd, const void *buff, size_t len, int flags); //TCP 数据读写: //recv()读取 sockfd 上的数据, buff 和 len 参数分别指定读缓冲区的位置和大小 //send()往 socket 上写入数据, buff 和 len 参数分别指定写缓冲区的位置和数据长度 //flags 参数为数据收发提供了额外的控制,一般设置为0 //返回值如果为0,说明对方退出; //返回值如果为-1,表示出错;接下来我们实现两个程序一个是服务器端,一个是客户端,客户端写数据并发送给服务器端,服务器端接收数据并返回ok。当客户端发送end时,客户端退出连接。
//ser.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> //转换IP地址 #include <netinet/in.h>//转换字节序 #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> //close函数 void AcceptClient(int sockfd) { while(1){ struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(client_addr)); socklen_t client_addr_len = sizeof(client_addr); int c = accept(sockfd,(struct sockaddr *)&client_addr, &client_addr_len); //accept没有客户端连接时会阻塞 if(c < 0){ perror("accept"); continue; } char *client_ip = inet_ntoa(client_addr.sin_addr); int client_port = ntohs(client_addr.sin_port); printf("客户端连接成功,IP: %s, 端口: %d\n", client_ip, client_port); while(1){ char buff[128] = {0}; memset(buff, 0, sizeof(buff)); int n = recv(c, buff, 127, 0); if(n < 0){ perror("recv"); break; } if(n == 0){ printf("客户端已关闭连接\n"); break; }else{ if(strncmp(buff, "end", 3) == 0){ close(c); break; } printf("收到客户端消息: %s\n", buff); send(c, "ok", strlen("ok"), 0); } } close(c); } } int main() { char *ip = "192.168.199.128"; int port = 5000; int sockfd = socket(AF_INET, SOCK_STREAM, 0); if(sockfd < 0){ perror("socket"); exit(1); } struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(port); //转换端口号为网络字节序 server_addr.sin_addr.s_addr = inet_addr(ip); //转换IP地址为网络字节序 socklen_t addr_len = sizeof(server_addr); int res = bind(sockfd, (struct sockaddr *)&server_addr, addr_len); if(res < 0){ perror("bind"); exit(1); } printf("服务器绑定成功,IP: %s, 端口: %d\n", ip, port); int backlog = 5; res = listen(sockfd, backlog); if(res < 0){ perror("listen"); exit(1); } printf("服务器已启动,等待连接...\n"); AcceptClient(sockfd); close(sockfd); return 0; }//cli.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> int main() { char * ip = "192.168.199.128"; int port = 5000; int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建套接字 if(sockfd < 0){ perror("socket"); exit(1); } struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(client_addr)); client_addr.sin_family = AF_INET; client_addr.sin_port = htons(port); client_addr.sin_addr.s_addr = inet_addr(ip); socklen_t addr_len = sizeof(client_addr); int res = connect(sockfd, (struct sockaddr *)&client_addr, addr_len); //连接服务器 if(res < 0){ perror("connect"); exit(1); } printf("连接服务器成功,IP: %s, 端口: %d\n", ip, port); while(1){ char sed[128] = {0}; memset(sed, 0, sizeof(sed)); printf("input:"); fgets(sed, 127, stdin); int slen = strlen(sed); if(slen > 0){ sed[slen - 1] = 0; //去掉换行符 }else{ continue; } send(sockfd, sed, strlen(sed), 0); //发送数据 if(strncmp(sed, "end", 3) == 0){ break; } char buff[128] = {0}; int n = recv(sockfd, buff, 127, 0); //接收数据 if(n < 0){ perror("recv"); break; }else if(n == 0){ printf("服务器已关闭连接\n"); break; }else{ printf("收到服务器消息: %s\n", buff); } } close(sockfd); return 0; }编译运行结果如下,我先将他在第二个终端上连接,之后在连接第三个终端上的cli程序,但是在服务器端它是不能做到并行处理的,它只能一个客户端处理完成断开连接后去处理下一个客户端,因为在代码逻辑上它是一个终端进入while(1)循环执行读写操作,遇到end后退出循环,断开本次连接后,才回去accept下一个连接,它是串行处理的,串行处理就会导致服务器运行效率低下。
当前服务器采用单线程阻塞 I/O 模型,属于串行处理: 同一时间只能服务一个客户端,必须等待当前客户端断开连接后,服务器才能回到 accept 接收下一个客户端连接。 这种模型无法并行处理多个客户端,在多用户访问场景下会导致服务效率低下、响应延迟。要实现多客户端并行处理,必须引入多进程或多线程并发处理:
- 多进程:每接入一个客户端,fork 一个子进程专门处理通信
- 多线程:每接入一个客户端,创建一个线程专门处理通信
两者都能让服务器同时服务多个客户端,互不阻塞。下面我先写出服务器端多进程的处理逻辑代码。在多进程处理中,我又单独封装了一个函数用于处理接收到的客户端数据。fork的时机应该放在accept之后,它从全连接队列中取得一个连接,子进程用于处理客户端数据,父进程则继续从accept中拿去连接。
//这是ser的代码,用上一次的cli.c可以直接验证 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> //转换IP地址 #include <netinet/in.h>//转换字节序 #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> //close函数 void DealOneClient(int c) //将处理客户端数据单独封装成一个函数 { while(1){ char buff[128] = {0}; memset(buff, 0, sizeof(buff)); int n = recv(c, buff, 127, 0); if(n < 0){ perror("recv"); break; } if(n == 0){ printf("客户端已关闭连接\n"); break; }else{ if(strncmp(buff, "end", 3) == 0){ close(c); break; } printf("客户端%d: %s\n",getpid(), buff); send(c, "ok", strlen("ok"), 0); } } close(c); printf("%d 已经断开连接\n",getpid()); } void AcceptClient(int sockfd) { while(1){ struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(client_addr)); socklen_t client_addr_len = sizeof(client_addr); int c = accept(sockfd,(struct sockaddr *)&client_addr, &client_addr_len); //在accept之后进行fork,父进程继续监听,子进程处理客户端数据 if(c < 0){ perror("accept"); continue; } char *client_ip = inet_ntoa(client_addr.sin_addr); int client_port = ntohs(client_addr.sin_port); printf("客户端连接成功,IP: %s, 端口: %d\n", client_ip, client_port); int res = fork(); //fork函数返回两次,父进程返回子进程的PID,子进程返回0 if(res < 0){ perror("fork"); close(c); continue; }else if(res == 0){ DealOneClient(c); } } } int main() { char *ip = "192.168.199.128"; int port = 5000; int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字,返回一个文件描述符 if(sockfd < 0){ perror("socket"); exit(1); } struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(port); //转换端口号为网络字节序 server_addr.sin_addr.s_addr = inet_addr(ip); //转换IP地址为网络字节序 socklen_t addr_len = sizeof(server_addr); int res = bind(sockfd, (struct sockaddr *)&server_addr, addr_len); //绑定套接字到指定的IP地址和端口号 if(res < 0){ perror("bind"); exit(1); } printf("服务器绑定成功,IP: %s, 端口: %d\n", ip, port); int backlog = 5; res = listen(sockfd, backlog); //将套接字设置为监听状态,等待客户端连接,backlog参数指定了连接请求队列的最大长度 if(res < 0){ perror("listen"); exit(1); } printf("服务器已启动,等待连接...\n"); AcceptClient(sockfd); //接受客户端连接并处理数据 close(sockfd); return 0; }我同过打开两个终端来测试是否可以同时处理两个客户端连接。这是不难看出客户端是可以支持并发处理多个客户端程序了。
下面是多线程处理处理的方法,同样也是相同的逻辑,在accept之后创建出线程,之后线程去执行处理客户端数据,主线程继续从全连接队列拿取数据,但是这里有一个需要注意的,主线程不能调用pthread_join函数,因为它会等待函数线程执行完成后在继续执行,如果函数线程执行过慢,则客户端连接会有延迟出现。所以这里我们介绍一个函数pthread_detath,它是用来分离函数线程,并自动回收系统资源的,但是它并不会阻塞住主线程,所以需要确保主线程不会先退出,如果先退出了必须在主线程最后调用pthread_exit(NULL);让其他线程运行完在结束,进程在结束(但是不推荐这种做法)。
#include <pthread.h> // 成功返回 0,失败返回错误号 int pthread_detach(pthread_t thread); //功能:分离状态的线程退出后,系统会自动回收它的资源,不需要其他线程调用 pthread_join 来等待回收。 //thread:要设置为分离状态的线程 ID#include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> //转换IP地址 #include <netinet/in.h>//转换字节序 #include <sys/types.h> #include <sys/socket.h> #include <unistd.h> //close函数 #include <pthread.h> void* DealOneClient(void *arg) //将处理客户端数据单独封装成一个函数 { int c = *(int *)arg; while(1){ char buff[128] = {0}; memset(buff, 0, sizeof(buff)); int n = recv(c, buff, 127, 0); if(n < 0){ perror("recv"); break; } if(n == 0){ printf("客户端已关闭连接\n"); break; }else{ if(strncmp(buff, "end", 3) == 0){ close(c); break; } printf("客户端%d: %s\n",c, buff); send(c, "ok", strlen("ok"), 0); } } close(c); printf("%d 已经断开连接\n",c); return NULL; } void AcceptClient(int sockfd) { while(1){ struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(client_addr)); socklen_t client_addr_len = sizeof(client_addr); int c = accept(sockfd,(struct sockaddr *)&client_addr, &client_addr_len); //在accept之后进行fork,父进程继续监听,子进程处理客户端数据 if(c < 0){ perror("accept"); continue; } char *client_ip = inet_ntoa(client_addr.sin_addr); int client_port = ntohs(client_addr.sin_port); printf("客户端连接成功,IP: %s, 端口: %d\n", client_ip, client_port); pthread_t id; int res = pthread_create(&id, NULL, DealOneClient, (void *)&c); //创建线程处理客户端数据 if(res != 0){ perror("pthread_create"); close(c); } pthread_detach(id); //分离线程,线程结束后自动释放资源 } } int main() { char *ip = "192.168.199.128"; int port = 5000; int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字,返回一个文件描述符 if(sockfd < 0){ perror("socket"); exit(1); } struct sockaddr_in server_addr; memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(port); //转换端口号为网络字节序 server_addr.sin_addr.s_addr = inet_addr(ip); //转换IP地址为网络字节序 socklen_t addr_len = sizeof(server_addr); int res = bind(sockfd, (struct sockaddr *)&server_addr, addr_len); //绑定套接字到指定的IP地址和端口号 if(res < 0){ perror("bind"); exit(1); } printf("服务器绑定成功: %s, 端口: %d\n", ip, port); int backlog = 5; res = listen(sockfd, backlog); //将套接字设置为监听状态,等待客户端连接,backlog参数指定了连接请求队列的最大长度 if(res < 0){ perror("listen"); exit(1); } printf("服务器已启动,等待连接...\n"); AcceptClient(sockfd); //接受客户端连接并处理数据 close(sockfd); return 0; }编译运行结果显示,同样也可以实现多客户端并发执行。
二、UDP服务器端与客户端编程流程
因为UDP协议是无连接的,不可靠的,数据报服务;所以它不需要listen来创建全连接队列和半连接队列,所以也没有accept来拿取全连接队列的描述符。故它的连接示意图如下:
socket()用来创建套接字,使用 udp 协议时,选择数据报服务 SOCK_DGRAM。sendto()用来发送数据,由于UDP 是无连接的,每次发送数据都需要指定对端的地址(IP 和端口); recvfrom()接收数据,每次都需要传给该方法一个地址结构来存放发送端的地址。recvfrom()可以接收所有客户端发送给当前应用程序的数据,并不是只能接收某一个客户端的数据。
ssize_t recvfrom( int sockfd, // 1. 套接字文件描述符 void *buff, // 2. 接收数据的缓冲区 size_t len, // 3. 缓冲区最大长度 int flags, // 4. 接收标志(一般填 0) struct sockaddr* src_addr, // 5. 【输出】发送方的地址(IP+端口) socklen_t *addrlen // 6. 地址长度(输入输出参数) ); 返回值:> 0:成功,返回实际收到的字节数; = 0:对方关闭连接;= -1:出错,用 errno 查看错误 ssize_t sendto( int sockfd, // 1. 套接字文件描述符 void *buff, // 2. 要发送的数据 size_t len, // 3. 数据长度 int flags, // 4. 发送标志(一般填 0) struct sockaddr* dest_addr, // 5. 【输入】目标地址(IP+端口) socklen_t addrlen // 6. 地址长度 ); 返回值:> 0:成功,返回实际发送的字节数;= -1:发送失败同样我们通过udp服务来实现服务器接收来自客户端从终端上读取的数据,但因为udp是无连接的,所以即使没有多进程或者多线程,它同样也可以同时接收来自不同客户端的数据,并发的执行处理逻辑。
//udp_ser.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <netinet/in.h> #include <sys/types.h> int main() { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字 if (sockfd == -1) { perror("socket"); exit(0); } struct sockaddr_in ser_addr; memset(&ser_addr, 0, sizeof(ser_addr)); ser_addr.sin_family = AF_INET; ser_addr.sin_port = htons(5000); ser_addr.sin_addr.s_addr = inet_addr("192.168.199.128"); socklen_t ser_len = sizeof(ser_addr); int res = bind(sockfd, (struct sockaddr *)&ser_addr, ser_len); //绑定套接字到指定的IP地址和端口号 if(res == -1) { perror("bind"); exit(0); } char buf[1024]; while (1){ memset(buf, 0, sizeof(buf)); struct sockaddr_in cli_addr; socklen_t cli_len = sizeof(cli_addr); ssize_t recv_len = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&cli_addr, &cli_len); //接收客户端发送的消息 if (recv_len == -1) { perror("recvfrom"); break; }else if(recv_len == 0) { printf("客户端已关闭连接\n"); break; }else{ printf("客户端IP%s:%d-> %s\n", inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port), buf); sendto(sockfd, "ok", 2, 0, (struct sockaddr *)&cli_addr, cli_len); //回复客户端消息 } } close(sockfd); //关闭套接字 return 0; }//udp_cli.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #include <netinet/in.h> #include <sys/types.h> int main() { int sockfd = socket(AF_INET, SOCK_DGRAM, 0); if (sockfd == -1) { perror("socket"); exit(EXIT_FAILURE); } struct sockaddr_in ser_addr; memset(&ser_addr, 0, sizeof(ser_addr)); ser_addr.sin_family = AF_INET; ser_addr.sin_port = htons(5000); ser_addr.sin_addr.s_addr = inet_addr("192.168.199.128"); socklen_t ser_len = sizeof(ser_addr); char buf[1024]; while (1){ memset(buf, 0, sizeof(buf)); printf("请输入要发送的消息: "); fgets(buf, sizeof(buf), stdin); int size = strlen(buf); if(size < 2)continue; buf[size - 1] = '\0'; ssize_t send_len = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&ser_addr, ser_len); if (send_len == -1) { perror("sendto"); break; } if(strncmp(buf, "end", 3) == 0){ break; } // struct sockaddr_in rcv_addr; // socklen_t rcv_len = sizeof(rcv_addr); // memset(&rcv_addr, 0, sizeof(rcv_addr)); // ssize_t recv_len = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&rcv_addr, &rcv_len); // if(recv_len == -1) // { // perror("recvfrom"); // break; // }else if(recv_len == 0) // { // printf("服务器已关闭连接\n"); // break; // }else{ // printf("服务器%s:%d-> %s\n", inet_ntoa(rcv_addr.sin_addr), ntohs(rcv_addr.sin_port), buf); // } } close(sockfd); return 0; }编译运行结果如下,下图也显示出它可以同时接收来自不同客户端的数据。
当我们将客户端的recvform注释防止其阻塞,不然客户端会阻塞在这里,不能在发送三个你数据,并且将服务器端的recvfrom中的接收大小设置为2,则会发现服务器端每次只接受2个字节大小的数据,其他数据都被丢弃了,这也体现udp传输的数据报服务以及不可靠的特点。
而且在服务器关闭状态下发送数据还是可以发送的,这体现出它们无连接,不可靠的特点,发送出的数据被直接丢弃掉了,重启服务器并不会接收未启动时客户端发来的数据。
通过以上代码与实验,我们已经掌握了TCP 的多进程 / 多线程并发模型,以及UDP 的无连接并发模型的基础用法。但在实际高并发场景中,多进程 / 多线程模型的资源开销与调度瓶颈会被放大,而 UDP 虽然天然无连接,但也无法解决 “如何高效同时监听多个文件描述符” 的问题。
无论是 TCP 还是 UDP,当服务器需要同时处理大量客户端连接 / 数据报时,传统的 “阻塞 I/O + 多进程 / 多线程” 模型会遇到明显的性能瓶颈:
- 进程 / 线程的创建与上下文切换开销巨大;
- 大量空闲连接会占用系统资源;
- 单线程阻塞 I/O 无法同时处理多个文件描述符。
为了解决这些问题,Linux 系统提供了I/O 多路复用技术,核心实现包括select ,poll 和 epoll。这些机制允许单进程 / 单线程同时监听多个文件描述符,仅在有数据就绪时才进行处理,大幅提升了服务器的并发处理能力与资源利用率。这些内容我会在之后的文章中详细讲述。
