前言
在基于udp的多线程模型中,希望一个线程对应一个连接/用户,或者一个线程对应一个用户。然而udp是无状态连接的,因此想要实现此想法,就没有想象这么顺利。 最终参考了腾讯手Q游戏中心后台开发人员黄日成的想法,才解决了此问题。
1、场景需求
我们程序希望使用udp协议监听,当有新的用户连接到来,程序可以使用一个线程与此连接通信。后续这个用户的信息都投递到这个线程当中。基本要求主要两点:
- 使用udp协议
- 一个线程对应一个连接(用户)
2、预先方案
(1) 每个线程都监听同一个端口(udp使用SO_REUSEPORT属性,可以实现, linux 3.9以上内核),每个线程单独处理一个用户任务, 这样每个线程分工就非常明确。
为了充分利用多核CPU资源,进行UDP的多处理,我们可以预先创建多个线程,每个线程都创建监听相同udp socket(IP、端口相同,分别使用SO_REUSEADDR、SO_REUSEPORT属性),这样利用内核的UDP socket查找算法来达到UDP的多进程负载均衡。
一切看上去好像挺不错的,然而,这样的方案会起这样问题:
由于线程中的监听的描述符是无状态的(udp),当有新用户udp连接进来的时候, 内核会根据负载均衡,唤醒其中一个线程监听的描述符。比如当用户A连接过来,内核随机唤醒线程1监听的描述符处理该连接。 当用户A第二次连接过来的时候,内核可能会唤醒线程2监听的描述符处理该连接,如下图所示。 那这样就做不到一个线程/描述符, 对应一个用户。 这样处理数据会非常麻烦。(每个线程监听相同端口,接收的数据会互相干扰)
当然,我们也可以将线程收到其他用户的消息投递到准确的线程中去。 比如当线程2收到用户A的消息,那么线程2将此消息内容传递到线程1中,这种投递有两种方式,如下。
- 将线程2接收消息,然后将此消息传递到线程1中。(这个过程增加了一次数据的拷贝)
- 将线程2监听的描述符投递到线程1,并且告诉线程1,有用户A的消息到达,让线程1使用线程2监听的描述符去接收用户A的消息。那么这种情况会造成其他线程可能使用自己的描述符fd接收消息,此时各个线程描述符接收消息时需要上锁。 这虽然避免了数据的拷贝,但是各个线程的描述符接收消息却需要上锁,这无疑也增大了开销。
因此,这种方案理论上可以实现,但是会有许多额外不必要的开销。
(2)每个线程监听不同端口, 这样各个线程的数据就不会干扰, 每个线程就可以很好单独处理一个用户任务。
基于上面的问题,主要是每个线程监听同一个端口造成数据干扰,很自然我们会想,那么让每个线程监听不同端口, 这样各个线程的数据就不会干扰, 每个线程就可以很好单独处理一个用户任务。
然而现实没有这么完美,这样程序需要为每个用户单独分配一个监听端口。而且还要提前告诉每个客户端,这样客户端才能连接过来,这个有点不切实际了。
3、最终方案
基于多线程的udp协议accept模型
因此,我们多么希望有一个方案,类似tcp协议accetp模型,按需分配tcp socket。 主线程监听,当有新的连接到达,主线程通过accept获得对应的fd,然后将此fd投递到空闲的线程中去,那么后续与这个用户的通信都可以在这个线程独立完成。
然而,几经波折之后发现,在udp协议中,也有类似的accept模型,可以达到tcp协议的accetp模型效果。(参考了腾讯手Q游戏中心后台开发人员黄日成的想法)
主线程使用描述符listen_fd监听指定udp端口,当有新的用户连接到达的时,主线程通过recvfrom接收消息,得到对方的ip和port,此时主线程再主动创建一个udp new_fd,也是与主线程绑定相同ip和port, 使用connect连接对方(udp中的connect作用与对方确定一个四元组的关系),这样此连接后续的消息都会投递此new_fd中,而不会投递到主线程listen_fd中,并且new_fd除了接收此用户的消息之外,不会接收其他新用户消息,即此new_fd已经与此用户建立一一对应关系。最终,再将此new_fd投递到空闲的线程中去, 这样也可以实现与这个用户的通信都在该线程独立完成。如下图
主线程监听udp指定端口,当新用户A连接到达时候,主线程创建对应的fd主动连接用户A(使用connect), 然后在将此fd投递到线程1中,那么后续与用户A的通信都在线程1独立完成。用户A的消息也会准确投递都线程1中。
上述方案,可能会有疑惑?
- 如何确保用户A的消息会发送到线程1的fd,而不会投递到主线程fd呢?
理论上,用户A的消息到达的时候,主线程fd和线程1 fd都可能会被唤醒去接收此消息(因为两者监听相同IP和port),但是,线程A的fd已经明确跟用户A绑定了四元组关系,内核会优先将消息投递到已经有明确关系的连接中,因此会优先投递到线程1 fd中。假如在线程1关闭此fd,那么此时用户A的消息将会投递到主线程fd中。
- 当已经存在多个fd(监听相同ip和port)的情况下, 新来的链接如何确保投递到主线程中呢?
如上图中,当程序除了主线程监听之外,还维护线程1 与用户A的连接情况下,理论上,由于主线程和线程1的fd都监听相同的ip和port, 那么当新的用户B连接情况下,用户B的连接可能投递这两者其中一个。 但是由于线程1 fd已经与用户A通过connect绑定一起,不再接收其他消息,因此不会接收到新用户B连接。所以新的用户B连接只会投递到主线程的fd当中,其他子线程的fd已经与特定用户绑定在一起了。
4、方案验证
实现步骤参考如下:(参考黄日成文章提出想法)
UDP svr创建UDP socket fd,设置socket为REUSEADDR和REUSEPORT、同时bind本地地址local_addr
1
2
3
4listen_fd = socket(PF_INET, SOCK_DGRAM, 0)
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt,sizeof(opt))
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt))
bind(listen_fd, (struct sockaddr * ) &local_addr, sizeof(struct sockaddr))创建epoll fd,并将listen_fd放到epoll中 并监听其可读事件
1
2
3
4
5epoll_fd = epoll_create(1000);
ep_event.events = EPOLLIN|EPOLLET;
ep_event.data.fd = listen_fd;
epoll_ctl(epoll_fd , EPOLL_CTL_ADD, listen_fd, &ep_event)
in_fds = epoll_wait(epoll_fd, in_events, 1000, -1);epoll_wait返回时,如果epoll_wait返回的事件fd是listen_fd,调用recvfrom接收client第一个UDP包并根据recvfrom返回的client地址, 创建一个新的socket(new_fd)与之对应,设置new_fd为REUSEADDR和REUSEPORT、同时bind本地地址local_addr,然后connect上recvfrom返回的client地址
1
2
3
4
5
6recvfrom(listen_fd, buf, sizeof(buf), 0, (struct sockaddr )&client_addr, &client_len)
new_fd = socket(PF_INET, SOCK_DGRAM, 0)
setsockopt(new_fd , SOL_SOCKET, SO_REUSEADDR, &reuse,sizeof(reuse))
setsockopt(new_fd , SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse))
bind(new_fd , (struct sockaddr ) &local_addr, sizeof(struct sockaddr));
connect(new_fd , (struct sockaddr * ) &client_addr, sizeof(struct sockaddr)将新创建的new_fd加入到epoll中并监听其可读等事件
1
2
3client_ev.events = EPOLLIN;
client_ev.data.fd = new_fd ;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_fd , &client_ev)当epoll_wait返回时,如果epoll_wait返回的事件fd是new_fd 那么就可以调用recvfrom来接收特定client的UDP包了
1
recvfrom(new_fd , recvbuf, sizeof(recvbuf), 0, (struct sockaddr * )&client_addr, &client_len)
服务端程序demo ,与上面稍微基本相同,不同是每个子线程单独使用一个epoll。
1 | /* |
客户端demo
1 | /* |
运行结果分析:
- 主线程监听fd是4,所对应epoll 描述符 3, 一开始用户A,B消息都准备投递到fd4
- 主线程收到创建线程与用户A的描述符是5,对应epoll是6, 可见用户A后续消息都准确投递到fd5
- 线程与用户B描述符7,,对应epoll是6, 可见用户B后续消息都准确投递到fd7
查看描述符对应的关系如下:
- 描述符fd 4 监听所有。
- 描述符fd 5已经绑定了localhost:12345(用户A)
- 描述符fd 7已经绑定了localhost:12346(用户B)
可知,通过验证,确定此方案可行。
5、小结
我们小组讨论了好几次,都卡在这个技术点上,没有比较好的方案。后续我在查找资料过程中,找到了上述的解决方案,得益于腾讯手Q游戏中心后台开发人员黄日成,在此表示感谢。