从一个mono的编译问题说开去,浅谈mono socket 实现
最近参考了同事的一份文章,编译了一份release版本的mono来尝试优化游戏的加载速度。
是的你没看错,unity自带的mono是debug版本的。在笔者所在项目用的unity5.6里面是debug版本的,在最新的unity官方的github中也是debug版本的。
有兴趣的同学可以看一下unity官方的build脚本,在脚本里面搜索 -fpic,你会看到如下构建选项
$ENV{CFLAGS} = "-DTIZEN -DLINUX -D__linux__ -DHAVE_USR_INCLUDE_MALLOC_H -DPAGE_SIZE=0x1000 -D_POSIX_PATH_MAX=256 -DS_IWRITE=S_IWUSR -DHAVE_PTHREAD_MUTEX_TIMEDLOCK -fpic -g -ffunction-sections -fdata-sections $ENV{CFLAGS}";
注意-g 这个选项,
在带上这个选项以后,在linux下用gcc编译会做如下两个额外的操作:
1. 创建符号表,符号表包含了程序中使用的变量名称的列表。
2. 关闭所有的优化机制,以便程序执行过程中严格按照原来的C代码进行。
是不是很厉害。。。
其实一开始我们也没发现这个问题,后来因为在ubuntu下面编译不过就让后台同事帮忙看看,后台同事看问题的时候,就顺便指出了这个问题。。
膜拜后台大佬。
剩下的就是修改构建选项,尝试编一个-O2的版本。
构建过程中没有遇到啥编译的问题,构建出来的版本的确也很nice。简单测试了一下,-O2选项构建出来的版本比原来更小,而且更快,一顿感叹为啥unity官方这么简单的问题都没发现,白白增加了不少加载时间。
-O2版本构建成功后,就把-O2版本替换了之前的-g版本发了一个体验服,让体验服的玩家帮忙跑跑。
体验服发布后,竟然获得了想象不到的好评。玩家纷纷反馈,真的比以前快了,感觉自己的老手机又行了。
连一向挑剔的策划,在体验了几次后,也给这次优化竖起了大拇指。
然鹅,存在就合理,这么简单的构建选项,unity官方肯定不会是客户端程序在维护,-O2和-g这两个的区别稍微有点gcc编译常识的人都会区分一下。
很快测试服的高端玩家就发现了一个诡异的bug,这个bug重现起来十分容易,就是简单开一局游戏后,再随便操作操作,然后重开一局,随便走走,这样重复几次。就有几率出现游戏无法连接服务器的情况。。
这样诡异的bug,自然是先从日志查起。。
很快就找到了一个符合当前表现的错误日志
从日志上来看,当前设置ReceiveBuffer的操作失败了,游戏当前的udp ReceiveBufferSize被设置成了一个极小的值,Receive buffer变小后,随着时间推移,Receivebuffer会逐渐被服务器下发的包塞满,进而导致了服务器后面的一系列一系列包被丢弃导致断线的情况。
根据日志也很好查到发生的代码
m_ClientSocket.ReceiveBufferSize = UDP_RECV_BUFFER_SIZE;
if (m_ClientSocket.ReceiveBufferSize < UDP_RECV_BUFFER_SIZE)
m_ClientSocket.ReceiveBufferSize = UDP_RECV_BUFFER_SIZE / 2;
WNLogger.GeneralError("[UdpSocket] Set ReceiveBufferSize failed, current: {0} desired: {1}",
m_ClientSocket.ReceiveBufferSize, UDP_RECV_BUFFER_SIZE);
这段逻辑是用来设置socket的ReceiveBuffer的,逻辑很简单很直白,而且也很久没有动过了,但是在我们把mono的编译级别从-g改成-O2后出错了。。。
看来我还是太年轻了,看着策划竖起的大拇指开始朝着中指转移的样子。我觉得这bug应该还可以抢救一下。
侯捷老师说过,源码面前了无秘密,我都写了这么多篇mono的文章了,这个ReceiveBufferSize肯定也是小case了?吧?
首先在mono的mcs文件夹里面搜一下ReceiveBufferSize
public int ReceiveBufferSize
if (disposed && closed)
throw new ObjectDisposedException(GetType().ToString());
return ((int)GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBuffer));
if (disposed && closed)
throw new ObjectDisposedException(GetType().ToString());
if (value < 0)
throw new ArgumentOutOfRangeException("value", "The value specified for a set operation is less than zero");
SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveBuffer, value);
里面的逻辑很简单,如果当前socket还是好的,就调用SetSocketOption函数,那么这个SetSocketOption函数的实现有在哪里呢?在mcs文件夹搜了半天没有找到,看起来是是一个mono内部实现的ICALL调用
在icall-def.h里面搜寻了一下,果然找到了相关的内部调用
ICALL(SOCK_18, "SetSocketOption_internal(intptr,System.Net.Sockets.SocketOptionLevel,
System.Net.Sockets.SocketOptionName,object,byte[],int,int&)",
ves_icall_System_Net_Sockets_Socket_SetSocketOption_internal)
在mono代码里面搜寻一下ves_icall_System_Net_Sockets_Socket_SetSocketOption_internal,发现这个函数在socket-io.h里面定义,在socket-io.c里面实现。从文件名看起来,这个应该是一个mono实现各种socket io函数的地方。我们接下来来看mono是怎样setsocketopt的。
代码很长,就说一下重点,在ves_icall_System_Net_Sockets_Socket_SetSocketOption_internal里面会通过函数convert_sockopt_level_and_name把C#层传过来的各种参数转变成一个个system_name就像下面这样
/*
* Returns:
* 0 on success (mapped mono_level and mono_name to system_level and system_name
* -1 on error
* -2 on non-fatal error (ie, must ignore)
static gint32 convert_sockopt_level_and_name(MonoSocketOptionLevel mono_level,
MonoSocketOptionName mono_name,
int *system_level,
int *system_name)
switch (mono_level) {
case SocketOptionLevel_Socket:
*system_level = SOL_SOCKET;
..........
switch(mono_name)
case SocketOptionName_ReceiveBuffer:
*system_name = SO_RCVBUF;
break;
比如我们上文中的RecviveBuffer就变成了*system_name = SO_RCVBUF;
SO_RCVBUF这个名字是不是很熟悉?如果还记得以前学过的计算机网络通信的话,应该对这个名字会有点印象。
有兴趣的同学可以去搜一下,笔者以前做过windows 网络的开发,一看这个就知道要做什么操作了。
在convert_sockopt_level_and_name函数内转换成功后,会继续挥刀SetSocketOpt
这里的逻辑很简单,经过转换后,有特殊处理的走特殊处理,没特殊处理的就走 _wapi_setsockopt (sock, system_level, system_name, buf, valsize);
从名字缩写看_wapi_setsockopt看起来像是windows api setsocket的缩写。搜一下,果然是一个在winsock.h的函数。。
我们跟进去看一下实现
看起来是用宏实现区分linux系统和windows系统实现,windows系统直接调用了系统提供的函数,linux做了一套封装。
至此我们应该可以从代码推断,mono一开始也是先实现一个windows版本,然后再通过各种中间层实现各种平台版本的。那么这种转接会不会有问题呢?我们等下再看。
_wapi_setsockopt的 windows实现版本是直接调用系统函数了,我们可以略过,直接看其他平台实现,其他平台的实现位于mono的io-layer的sockets.c下面,由于windows版本没有引用,所以需要通过notepad++的文件夹搜索才能找到,我们来看代码。
int _wapi_setsockopt(guint32 fd, int level, int optname,
const void *optval, socklen_t optlen)
gpointer handle = GUINT_TO_POINTER (fd);
int ret;
const void *tmp_val;
struct timeval tv;
if (startup_count == 0) {
WSASetLastError (WSANOTINITIALISED);
return(SOCKET_ERROR);
if (_wapi_handle_type (handle) != WAPI_HANDLE_SOCKET) {
WSASetLastError (WSAENOTSOCK);
return(SOCKET_ERROR);
tmp_val = optval;
if (level == SOL_SOCKET &&
(optname == SO_RCVTIMEO || optname == SO_SNDTIMEO)) {
int ms = *((int *) optval);
tv.tv_sec = ms / 1000;
tv.tv_usec = (ms % 1000) * 1000; // micro from milli
tmp_val = &tv;
optlen = sizeof (tv);
#if defined (__linux__)
} else if (level == SOL_SOCKET &&
(optname == SO_SNDBUF || optname == SO_RCVBUF)) {
/* According to socket(7) the Linux kernel doubles the
* buffer sizes "to allow space for bookkeeping
* overhead."
int bufsize = *((int *) optval);
bufsize /= 2;
tmp_val = &bufsize;
#endif
ret = setsockopt (fd, level, optname, tmp_val, optlen);
if (ret == -1) {
gint errnum = errno;
errnum = errno_to_WSA (errnum, __func__);
WSASetLastError (errnum);
return(SOCKET_ERROR);
return(ret);
这个就是linux下面的实现,函数注释完全代码工整,简单点说就是经过一系列校验和检查后,调用linux下的函数setsockopt。看起来应该是没问题,里面还有一段特殊的注释
/* According to socket(7) the Linux kernel doubles the
* buffer sizes "to allow space for bookkeeping
* overhead."
linux下的socket 7 版本把现在的buffersize double了一下,用来bookkeeping(记账)。应该linux下把buffer size变大了,方便定位问题意思。
好了,我们都看了这么多代码了,那么我们一开始要解决的问题是什么。。。
我们的问题是在把mono的编译级别从g改成-O2后。在c#里面设置ReceiveBufferSize操作似乎出现了问题,明明设置了一个比较大的值,但是一看发现却变成了一个极小值。。
那我们分析了这么久代码,有结果了吗?
没有(摔)。。。。
在我看代码的同时,另外一个帮忙看问题的同事在尝试把-O2的构建选项拆开来,然后一个一个加上来尝试重现问题。
他的这种类似黑盒测试的方法,比我这样的看代码更快找到了问题。
请忽略我狗啃一样的打码,-fno-strict-aliasing 关闭严格转型限制。
有兴趣的同学可以看一下下面两个延伸阅读
嗯,mono的程序员不行。。代码写出了undefined behaviour。。什么是undefined behaviour这个又可以好好掰次掰次了。
有兴趣的同学可以知乎搜一下,里面一堆鬼故事。。。
如果想更进一步了解一些知识,可以看下面的进阶。
好了,至此真相大白,mono的代码写的有问题,这个问题很可能是因为之前复用的vc6时代不规范的代码导致了undefined behaviour,解决方案也比较简单。
加上 -fno-strict-aliasing 就好了。
那么事情到此就结束了吗?
其实并没有,虽然知道mono的代码发生了UB,但是到底哪段代码发生了UB,不知道各位朋友有没有看出来。
在查找到底哪里ub的问题笔者做了很多猜想。。最后都被各路c++大佬打的抱头痛哭。期间各种心路历程就不说了,贴一段大佬的精辟总结。 @冒泡
期间一度神情恍惚,感觉c++是门玄学语言。此处再次推荐大家看一下ub相关的鬼故事
好了不卖关子了。
int _wapi_setsockopt(guint32 fd, int level, int optname,
const void *optval, socklen_t optlen)
#if defined (__linux__)
} else if (level == SOL_SOCKET &&
(optname == SO_SNDBUF || optname == SO_RCVBUF)) {
/* According to socket(7) the Linux kernel doubles the
* buffer sizes "to allow space for bookkeeping
* overhead."
int bufsize = *((int *) optval);
bufsize /= 2;
tmp_val = &bufsize;
#endif
出问题的代码就在 int bufsize =*((int*) optval); 这个操作里面,套用UB鬼故事的里面的一段话
这是UB,因为不能把一个指向类型T1(const void *)的指针认为是一个指向类型T2(int*)的指针,
会造成Pointer aliasing.
解决方法也很简单,如果看过上面ub鬼故事的同学应该可以大致了解,如果是c++最稳妥的解决方案是memcpy。。。
不过还好mono是一个c构建的工程,我们可以用c的union来规避这个问题。
int _wapi_setsockopt(guint32 fd, int level, int optname,
const void *optval, socklen_t optlen)
union UB
int i;
char f;
union UB u;
#if defined (__linux__)
} else if (level == SOL_SOCKET &&
(optname == SO_SNDBUF || optname == SO_RCVBUF)) {
/* According to socket(7) the Linux kernel doubles the
* buffer sizes "to allow space for bookkeeping
* overhead."
u.f = *optval;
int bufsize = u.i;