UDP协议探索
这篇文章主要讲解udp编程和tcp编程的异同点。讲解自己在写代码过程中遇到的一些问题和理解。
最后实现一个udp client 和一个udp server ,用epoll IO复用的框架完成对多个client消息的处理与连接。
UDP和TCP的区别
TCP传输有几个特征
1.数据在传输过程中不会消失
因为TCP是按字节传输可以将TCP的字节流比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;
2.数据是按照顺序传输的;
同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。tcp的重传和确认机制会保证字节流的顺序是一定正确的。应用层处理时只负责从网络协议栈里读就行了。
3.数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。
TCP协议栈的内部有一个缓冲区(也就是字符数组),通过 socket 传输的数据将保存到这个缓冲区。接收端在收到数据后并不一定立即读取,只要数据不超过缓冲区的容量,接收端有可能在缓冲区被填满以后一次性地读取,也可能分成好几次读取。
也就是说,不管数据分几次传送过来,接收端在应用层只需要根据自己的要求读取,不用非得在数据到达时立即读取。传送端有自己的节奏,接收端也有自己的节奏,它们是不一致的。
UDP传输有以下几个特征:
1.强调快速传输而非传输顺序
UDP传输是面向报文的,包的先后顺序不保证,可能先发出去的包后收到。所以在应用层要自己做好包的顺序排列。
2.传输的数据可能丢失也可能损毁;
UDP传输没有确认机制,所以不管包到没到,发送端都不管了。所以要想让数据不出现丢失
3.限制每次传输的数据大小;
4.数据的发送和接收是同步的(有的教程也称“存在数据边界”)。
因为是面向报文的服务,所以发送端发送一个报文,接收端就会接受一个报文。
代码编写
代码放在github
服务端
首先我们创建listenfd用来监听,并且设置一些必要的属性
//先创建listenfd
if((listenfd=socket(PF_INET,SOCK_DGRAM,0))==-1){
perror("socket");
exit(1);
}
//设置一些属性,快速重连和多线程监听同一端口
ret=setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
if(ret){
exit(1);
}
ret=setsockopt(listenfd,SOL_SOCKET,SO_REUSEPORT, &opt, sizeof(opt));
if(ret){
exit(1);
}
//设置非阻塞
int flags=fcntl(listenfd,F_GETFL,0);
flags |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flags);
1.PF_INET和AF_INET有时候会混用的问题
AF = Address Family
PF = Protocol Family
AF_INET = PF_INET
所以,理论上建立socket时是指定协议,应该用PF_xxxx,设置地址时应该用AF_xxxx。当然AF_INET和PF_INET的值是相同的,混用也不会有太大的问题。
2.SO_REUSEADDR和SO_REUSEPORT
- SO_REUSEADDR:这个选项允许在同一端口上快速重用处于TIME_WAIT状态的套接字。当一个套接字关闭后,它会进入TIME_WAIT状态一段时间,以确保所有的数据包都被正确处理。设置SO_REUSEADDR选项后,可以立即重新使用相同的端口进行绑定。这对于服务器程序在关闭后快速重新启动非常有用。
- SO_REUSEPORT:这个选项允许多个套接字绑定到相同的端口上。在多线程或多进程服务器中,可以使用SO_REUSEPORT选项来实现负载均衡,让多个进程或线程同时监听相同的端口。这样,当有新的连接到达时,操作系统会将连接分发到不同的套接字上,实现并发处理。
3.设置非阻塞是为了什么
我们一般使用IO复用来实现并发模型,如果我们默认监听套接字为阻塞模式,假设一种场景如下:
- 客户通过connect向TCP服务器发起三次握手
- 三次握手完成后,触发TCP服务器监听套接字的可读事件,IO复用返回(select、poll、epoll_wait)
- 客户通过RST报文取消连接 TCP服务器调用accept接受连接,此时发现内核已连接队列为空(因为唯一的连接已被客户端取消)
- 程序阻塞在accept调用,无法响应其它已连接套接字的事件 为了防止出现上面的场景,我们需要把监听套接字设置为非阻塞
客户端发起connect后,客户端发送syn包。
服务器收到连接,poxi协议栈会在syn队列中加入新的节点,并且发送syn和ack,注意此时在服务端epoll_wait已经检测到可读事件了,会结束阻塞。然后调用accept。
此时客户端收到ack,如果发送了RST包,所以不会增加accept队列的节点,但是accept已经持续阻塞,由于IO复用是单线程,会导致线程一直阻塞在accept。
所以要设置非阻塞socket。
接着我们绑定IP
//设置IP地址
bzero(&my_addr,sizeof(my_addr));
my_addr.sin_family=AF_INET;
my_addr.sin_port = htons(port);
my_addr.sin_addr.s_addr = INADDR_ANY;
//调用bind绑定
if (bind(listenfd, (struct sockaddr *) &my_addr, sizeof(struct sockaddr)) == -1) {
perror("bind");
exit(1);
} else {
printf("IP bind OK\n");
}
注意UDP是没有listen和accept
因为 UDP 是无连接的,所以在编程时不需要调用 listen 函数和 accept 函数。
创建epoll池
将listenfd加入监听
// 创建epoll池
epfd=epoll_create(1);
//listenfd加入epoll监听
ev.events= EPOLLIN | EPOLLET; //边沿触发
ev.data.fd=listenfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) < 0) {
fprintf(stderr, "epoll set insertion error: fd=%dn", listenfd); //格式化输出错误信息
return -1;
} else {
printf("ep add OK\n");
}
这里我们设置了边沿触发
epoll循环 和tcp逻辑相似
//进入epoll循环
while(1){
int nread=epoll_wait(epfd,events,10000,-1);
if (nread== -1) { //出错
perror("epoll_wait");
break;
}
int i;
for (i = 0; i < nread; ++i) {
if (events[i].data.fd == listenfd) { //接受连接
int clientfd;
while(1){
clientfd=udp_accept(listenfd,my_addr);
if (clientfd == -1) break;
//将clientfd加入监听
ev.events= EPOLLIN; //边沿触发
ev.data.fd=clientfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev) < 0) {
fprintf(stderr, "epoll set insertion error: fd=%dn", clientfd); //格式化输出错误信息
return -1;
} else {
printf("ep add OK\n");
}
}
}
else {
//直接读消息
read_data(events[i].data.fd);
}
}
}
这里需要讲解的是
循环接受连接
while(1){
clientfd=udp_accept(listenfd,my_addr);
}
因为之前我们设置了边沿触发
ev.events= EPOLLIN | EPOLLET; //边沿触发
所以在这里要把连接读完。否则会等到下一次连接发起才能读。
然后是udp_accept函数
int udp_accept(int listenfd,struct sockaddr_in my_addr){
int new_sd = -1;
int ret = 0;
int reuse = 1;
char buf[16];
struct sockaddr_in peer_addr; //对方的地址
socklen_t cli_len = sizeof(peer_addr);
//接收信息
ret = recvfrom(listenfd, buf, 16, 0, (struct sockaddr *)&peer_addr, &cli_len);
if (ret < 0) {
return -1;
}
//创建一个新的socket
if ((new_sd = socket(PF_INET, SOCK_DGRAM, 0)) == -1) {
perror("child socket");
exit(1);
} else {
printf("%d, parent:%d new:%d\n",count++, listenfd, new_sd); //1023
}
buf[ret]='\0';
printf("ret: %d, buf: %s from %d\n ", ret, buf,new_sd);
ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse));
if (ret) {
exit(1);
}
ret = setsockopt(new_sd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse));
if (ret) {
exit(1);
}
//绑定本地地址
ret = bind(new_sd, (struct sockaddr *) &my_addr, sizeof(struct sockaddr));
if (ret){
perror("chid bind");
exit(1);
} else {
}
//设置地址的协议
peer_addr.sin_family = PF_INET;
if (connect(new_sd, (struct sockaddr *) &peer_addr, sizeof(struct sockaddr)) == -1) {
perror("chid connect");
exit(1);
} else {
}
return new_sd;
}
需要注意,因为udp没有accept函数,所以我们在这里需要自己实现一个accept的功能。
我们首先调用recvfrom接收信息。拿到对方的IP地址
然后我们创建一个新的socket对象。
- tcp的accept方法中,其实也是创建了新的socket对象,然后将该对象的fd返回。
- 所以我们需要效仿TCP的做法
- 紧接着我们也给它设置一些参数,绑定本地的地址。
- 最后调用connect方法。
这里对connect方法做说明:
udp_accept函数并没有真正调用connect函数来建立连接。它使用了connect函数的模拟行为,
模拟行为是指: 为UDP套接字设置了一个默认的目的地,以便后续的数据发送操作可以使用简化的send函数。这样做是为了避免每次发送数据时都要指定目的地的地址信息。
connect函数的行为会根据套接字的类型来进行区分。在给定的代码中,connect函数的调用是针对UDP套接字(SOCK_DGRAM)进行的
在TCP套接字(SOCK_STREAM)上调用connect函数时,会建立一个持久的连接,连接的两端可以进行双向的数据交换。
但是,在UDP套接字上调用connect函数时,实际上并不会建立持久的连接。UDP是无连接的协议,每个数据报都是独立的。
接着是read_data函数
void read_data(int fd){
char recvbuf[MAXBUF + 1];
int ret;
struct sockaddr_in client_addr;
socklen_t cli_len=sizeof(client_addr);
bzero(recvbuf, MAXBUF + 1);
ret = recvfrom(fd, recvbuf, MAXBUF, 0, (struct sockaddr *)&client_addr, &cli_len);
if (ret > 0) {
printf("read[%d]: %s from %d\n", ret, recvbuf, fd);
} else {
printf("read err:%s %d\n", strerror(errno), ret);
}
}
这里比较简单,寝室就是从fd调用recvfrom读数据
客户端
这里我直接贴代码 比较简单
就是从2025端口开始不断建立udp连接发包
void createClient(int id,int myPort,int peerPort){
int reuse = 1;
int socketFd;
struct sockaddr_in peer_Addr;
peer_Addr.sin_family = PF_INET;
peer_Addr.sin_port = htons(peerPort);
peer_Addr.sin_addr.s_addr = inet_addr("127.0.0.1");
struct sockaddr_in self_Addr;
self_Addr.sin_family = PF_INET;
self_Addr.sin_port = htons(myPort);
self_Addr.sin_addr.s_addr = inet_addr("0.0.0.0");
if ((socketFd = socket(PF_INET, SOCK_DGRAM| SOCK_CLOEXEC, 0)) == -1) {
perror("child socket");
exit(1);
}
int opt=fcntl(socketFd,F_GETFL);
fcntl(socketFd,F_SETFL,opt|O_NONBLOCK);
if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))){
exit(1);
}
if(setsockopt(socketFd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))){
exit(1);
}
if (bind(socketFd, (struct sockaddr *) &self_Addr, sizeof(struct sockaddr))){
perror("chid bind");
exit(1);
} else {
}
if (connect(socketFd, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr)) == -1) {
perror("chid connect");
exit(1);
}
usleep(1); // --> key
char buffer[1024] = {0};
memset(buffer, 0, 1024);
sprintf(buffer, "hello %d", id);
sendto(socketFd, buffer, strlen(buffer), 0, (struct sockaddr *) &peer_Addr, sizeof(struct sockaddr_in));
}
void serial(int clinetNum){
for(int i=1;i<=clinetNum;i++){
createClient(i,2025+i,1234);
}
}
int main(int argc, char * argv[])
{
serial(1024);
printf("serial success\n");
return 0;
}
测试
最后是测试截图
因为最大打开数是1024个文件,代码在1023附近终止。
可以设置linux最大打开的句柄来增加连接数。这里就不再赘述。