为了更好的聊今天的话题,我们先假设一个场景。
我相信我读者大部分都是做互联网应用开发的,可能对游戏的架构不太了解。
我们想象中的游戏架构是下面这样的。
想象中的游戏架构
也就是用户客户端直接连接游戏核心逻辑服务器,下面简称GameServer。GameServer主要负责实现各种玩法逻辑。
这当然是能跑起来,实现也很简单。
但这样会有个问题,因为游戏这块蛋糕很大,所以总会遇到很多挺刑的事情。
如果让用户直连GameServer,那相当于把GameServer的ip暴露给了所有人。
不赚钱还好,一旦游戏赚钱,就会遇到各种攻击。
你猜《羊了个羊》最火的时候为啥老是崩溃?
假设一个游戏服务器能承载4k玩家,一旦服务器遭受直接攻击,那4k玩家都会被影响。
这攻击的是服务器吗?这明明攻击的是老板的钱包。
所以很多时候不会让用户直连GameServer。
而是在前面加入一层网关层,下面简称gateway。类似这样。
实际的某些游戏架构
GameServer就躲在了gateway背后,用户只能得到gateway的IP。
然后将大概每100个用户放在一个gateway里,这样如果真被攻击,就算gateway崩了,受影响的也就那100个玩家。
由于大部分游戏都使用TCP做开发,所以下面提到的连接,如果没有特别说明,那都是指TCP连接。
那么问题来了。
假设有100个
用户连gateway,那gateway跟GameServer之间也会是 100个
连接吗?
当然不会,gateway跟GameServer之间的连接数会远小于100。
因为这100个用户不会一直需要收发消息,总有空闲的时候,完全可以让多个用户复用同一条连接,将数据打包一起发送给GameServer,这样单个连接的利用率也高了,GameServer 也不再需要同时维持太多连接,可以节省了不少资源,这样就可以多服务几个大怨种金主。
我们知道,要对网络连接写数据,就要执行 send(socket_fd, data)
。
于是问题就来了。
已知多个用户共用同一条连接。
现在多个用户要发数据,也就是多个用户线程需要写同一个socket_fd。
那么,socket是并发安全的吗?能让这多个线程同时并发写吗?
并发读写socket
写TCP Socket是线程安全的吗?
对于TCP,我们一般使用下面的方式创建socket。
sockfd=socket(AF_INET,SOCK_STREAM, 0))
返回的sockfd
是socket的句柄id,用于在整个操作系统中唯一标识你的socket是哪个,可以理解为socket的身份证id。
创建socket时,操作系统内核会顺带为socket创建一个发送缓冲区和一个接收缓冲区。分别用于在发送和接收数据的时候给暂存一下数据。
写socket的方式有很多,既可以是send
,也可以是write
。
但不管哪个,最后在内核里都会走到 tcp_sendmsg()
函数下。
// net/ipv4/tcp.c int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t size) { // 加锁 lock_sock(sk); // ... 拷贝到发送缓冲区的相关操作 // 解锁 release_sock(sk); }
在tcp_sendmsg
的目的就是将要发送的数据放入到TCP的发送缓冲区中,此时并没有所谓的发送数据出去,函数就返回了,内核后续再根据实际情况异步发送。关于这点,我在之前写过的 《动图图解 | 代码执行send成功后,数据就发出去了吗?》有更详细的介绍。
tcp_sendmsg 逻辑
从tcp_sendmsg
的代码中可以看到,在对socket的缓冲区执行写操作的时候,linux内核已经自动帮我们加好了锁,也就是说,是线程安全的。
所以可以多线程不加锁并发写入数据吗?
不能。
问题的关键在于锁的粒度。
但我们知道TCP有三大特点,面向连接,可靠的,基于字节流的协议。
TCP是什么
问题就出在这个"基于字节流",它是个源源不断的二进制数据流,无边界。来多少就发多少,但是能发多少,得看你的发送缓冲区还剩多少空间。
举个例子,假设A线程想发123
数据包,B线程想发456
数据包。
A和B线程同时执行send()
,A先抢到锁,此时发送缓冲区就剩1个
数据包的位置,那发了"1"
,然后发送缓冲区满了,A线程退出(非阻塞),当发送缓冲区腾出位置后,此时AB再次同时争抢,这次被B先抢到了,B发了"4"
之后缓冲区又满了,不得不退出。
重复这样多次争抢之后,原本的数据内容都被打乱了,变成了142356
。因为数据123是个整体
,456又是个整体
,像现在这样数据被打乱的话,接收方就算收到了数据也没办法正常解析。
并发写socket_fd导致数据异常
也就是说锁的粒度其实是每次"写操作",但每次写操作并不保证能把消息写完整。
那么问题就来了,那是不是我在写整个完整消息之前加个锁,整个消息都写完之后再解锁,这样就好了?
类似下面这样。
// 伪代码 int safe_send(msg string) { target_len = length(msg) have_send_len = 0 // 加锁 lock(); // 不断循环直到发完整个完整消息 do { send_len := send(sockfd,msg) have_send_len = have_send_len + send_len } while(have_send_len < target_len) // 解锁 unlock(); }
这也不行,我们知道加锁这个事情是影响性能的,锁的粒度越小,性能就越好。反之性能就越差。
当我们抢到了锁,使用 send(sockfd,msg)
发送完整数据的时候,如果此时发送缓冲区正好一写就满了,那这个线程就得一直占着这个锁直到整个消息写完。其他线程都在旁边等它解锁,啥事也干不了,焦急难耐想着抢锁。
但凡某个消息体稍微大点,这样的问题就会变得更严重。整个服务的性能也会被这波神仙操作给拖垮。
归根结底还是因为锁的粒度太大了。
有没有更好的方式呢?
其实多个线程抢锁,最后抢到锁的线程才能进行写操作,从本质上来看,就是将所有用户发给GameServer逻辑服务器的消息给串行化了,
那既然是串行化,我完全可以在在业务代码里为每个socket_fd配一个队列来做,将数据在用户态加锁后塞到这个队列里,再单独开一个线程,这个线程的工作就是发送消息给socket_fd。
于是上面的场景就变成了下面这样。
并发写到加锁队列后由一个线程处理
于是在gateway层,多个用户线程同时写消息时,会去争抢某个socket_fd对应的队列,抢到锁之后就写数据到队列。而真正执行 send(sockfd,msg)
的线程其实只有一个。它会从这个队列中取数据,然后不加锁的批量发送数据到 GameServer。
由于加锁后要做的事情很简单,也就塞个队列而已,因此非常快。并且由于执行发送数据的只有单个线程,因此也不会有消息体乱序的问题。