写bug凭实力,debug靠运气

动态类型

支持动态类型的编程语言在定义变量时并不需要指定类型,只在运行时才做类型检查。动态类型带来极大方便的同时,也会埋下不易察觉的隐患。

def add(a, b):
    return a + b

print(add(1, 2))
print(add('1', '2'))
# print(add(1, '2')) # 非法

def equal(a, b):
    return a == b

print(equal(1, '1'))

例如,用python写一个add方法,传入ab两个参数。使用时,既可以实现整数相加,也可以实现字符串拼接。

只要传入的两种类型可以进行+运算,就是合法的。因为数值不可以与字符串做加法,所以运行add(1, '2')时会报错。

任何类型之间都可以做逻辑判断,例如,equal(1, '1')可以返回False,类型不同也不会导致报错

据b站发布的《2021.07.13 我们是这样崩的》介绍,2021年7月13发生的b站宕机事故就是由一个简单的动态类型导致的:
在这里插入图片描述

_gcd函数收到的入参b可能为"0",因为"0"不等于数值0,所以递归调用_gcb("0", a % "0")

进行数值运算时,a % "0"被转为a % 0,结果变为nan,实际调用_gcb("0", nan),最终陷入_gcd(nan,nan)的死循环中。

所以,任何报错的bug都不可怕,不会报错的bug才是最可怕的

溢出

当数值超出了合法的最大或最小值时,也可能会带来一些比较严重的后果。

**合法范围可以是人为规定的。**在2015年4月21日之前,上交所的日成交最大值限定是1万亿,而这一天收盘沪市总成交达到1.1476万亿,导致成交数据无法正常显示。此后,万亿成交也成为了常态。

**合法范围也可以是无需证明的公理性质的常识。**在2020年4月20日之前,以负值卖出资产可能还只是一个笑话。这一天5月到期的WTI期货价格跌破0美元,最低到-40.32美元,收于-37.63美元,这意味着,卖出一张期货合约,不仅不会有收益,还需要向买方支付37.63美元。超出了人们的常识。这个不会在小说中出现的情景,却在现实世界中发生了。现实就是如此的荒诞。

回到计算机层面,数值类型的范围由占用的内存长度决定。

例如,int类型占用4个字节(Byte),每个字节有8个比特(bit),其中首位是符号位,符号位为0表示正数,符号位为1表示负数,所以可表示的最大数值就是:

2^31 - 1 = 0111 1111 1111 1111 1111 1111 1111 1111 = 2147483647

当存储的数值超过这个最大值时:

2^31 = 2^31 - 1 + 1 = 1000 0000 0000 0000 0000 0000 0000 0000 = -2147483648

由于符号位为1,就变成了负数,溢出了。

无符号整型unsinged int不需要符号位,所以可表示的最大值是:

2^31 - 1 = 1111 1111 1111 1111 1111 1111 1111 1111 = 4294967295

当存储的数值超过这个最大值时:

2^31 = 2^31 - 1 + 1 = 0000 0000 0000 0000 0000 0000 0000 0000 = 0

因为合法的内存空间只有4个字节,这4个合法内存表示的数值就是0,也溢出了。

溢出的问题是如此的常见,例如:

  • unsigned int存储文件大小时,超过4GB就会溢出。
  • unsigned int存储毫秒计数器时,大概49天之后就会溢出。

溢出导致的后果也是灾难性的,有时甚至会带来巨额的经济损失。

例如,某项目中有一个批量转账方法,向_receivers列表中的每个地址转入资金_value

function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value;
    require(cnt > 0 && cnt <= 20);
    require(_value > 0 && balances[msg.sender] >= amount);

    balances[msg.sender] = balances[msg.sender].sub(amount);
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] = balances[_receivers[i]].add(_value);
        Transfer(msg.sender, _receivers[i], _value);
    }
    return true;
 }

首先检查收款人列表不能超过20人,然后检查转出账户是否有足够金额。这两个条件都满足,才可以进行转账。

问题则出在总转出金额amount的计算上。amountuint256,最大合法数值是2^256-1。当

cnt = 2
_value = (2^256 / 2)
amount = uint256(cnt) * _value = 2^256

计算出来的amount值超过2^256-1,溢出导致amount实际为0。于是,检查账户资金就通过了:

balances[msg.sender] >= amount

接下来执行转账操作,先从转出账户上减去amount

balances[msg.sender].sub(amount)

amount0,所以转出账户的资金不变。

然后,在每个目标账户加上_value

balances[_receivers[i]] = balances[_receivers[i]].add(_value)

_value大于0,所以目标账户的资金增加。

相当于,你拿着一张存有1元的银行卡申请向目标账户转64亿元,银行通过了校验,在目标账户增加了64亿元,然后转出账户的资金并没有变少。

变量的生命周期

弄清楚变量在程序中的生命周期是至关重要的,否则就可能导致意想不到的问题。

**python的变量分为可变类型和不可变类型。例如数值、字符串都是不可变类型,列表、字典是可变类型。以可变类型作为参数时,传入的是值,以不可变类型传入参数时,传入的是引用。**例如:

def test_int(a):
    a = 10


def test_list(a):
    a[0] = 10


a_int = 0
a_list = [0]

test_int(a_int)
test_list(a_list)

print('a_int:', a_int)
print('a_list:', a_list)

test_int因为传入的是值,并不改变全局a_int的数值。test_list传入的是引用,全局a_list也随之改变。

文章《一个 Python Bug 干倒了估值 1.6 亿美元的公司》中讲的故事就是由这个问题引发的。

项目服务端定义了一个方法来检索用户信息:

def get_user_by_ids(ids=[])

其中默认参数是一个空列表,运行时这个作为参数的空列表只会被创建一次,每次调用都是第一次被创建的列表的引用。

也就是说,随着这个方法调用次数的增加,越来越多的用户信息被追加到参数列表中,当在每次请求中需要检索数以万计的用户时,就会导致页面崩掉。

类似的问题在c++语言中更为常见。因为c++既可以在栈上自动分配内存空间,也允许使用new在堆上主动分配空间。

区别在于,由栈上分配的内存空间在出作用域之后会被自动回收,在堆上new的内存空间需要主动释放。保证地址合法性就显得尤为重要,既要避免在作用域外使用指向栈上的指针,也要避免使用指向已经被释放的堆上的指针。

我也遇到过相关的问题。程序分别创建一个md实例和一个trader实例,将trader的地址绑定到md上,当md收到的行情触发条件时调用trader的方法报单:

int main()
{
    CMd md();
    CTrader trader();
    md.linkTrader(&trader);
}

md实例和trader实例都在栈上,作用域整个main函数运行周期,没有任何问题。

后来,由于增加了新业务需求,可能不需要创建trader实例,所以我就增加了一个判断:

int main()
{
    CMd md();
    if (bNeedTrader)
    {
        CTrader trader();
        md.linkTrader(&trader);
    }
}

看起来都挺正常,编译也通过了,运行也没问题。但是,当触发条件需要报单时,程序就崩了。

经过分析,发现这是因为trader实例的作用域只在if方法中,if块结束后,因为trader是在栈上创建的,所以会被自动释放,md绑定的这个地址就变成非法的了。当触发条件时,调用这个非法地址导致系统崩溃。调整代码如下:

int main()
{
    CMd md();
    CTrader* pTrader = NULL;
    if (need_trader)
    {
        pTrader = new CTrader();
        md.linkTrader(pTrader);
    }
    ……
    if(NULL != pTrader)
    {
        delete pTrader;
    }
}

此时,pTrader是在堆上创建的,主动释放之前都是有效的,触发行情时调用指向这块地址的指针就可以正常报单了。

编程语言之外的逻辑

任何编程语言逻辑内的bug都是可以理解的,要发现并解决超出编程语言逻辑之外的bug就需要那么一点运气了。

在最近的一个项目中,遇到了一个困扰我许久的小问题。

创建了两个线程订阅行情,其中一个线程与服务器建立连接时,总会出现断开、重连,再断开,再重连,反复数十次之后才正常。

void subscribeAll(CBaseMd* pBaseMd, TMSubscribeVectorType& vSubscribeVector)
{
    for (auto pVitalData : vSubscribeVector)
    {
    	pBaseMd->subscribe(pVitalData->ExchangeID, pVitalData->StandardID);
    }
}

虽然只需要早点启动程序,就可以保证开盘前正常连接上,但是这个问题仍然让我如坐针毡、如芒刺背、如鲠在喉。

念念不忘了许久,终于有一天,我发现两个线程订阅的数量不一样,断开重连的总是那个订阅数量比较多的线程。我大概想明白了,是服务端在短时间内接收大量订阅请求时把我的连接断开了。

那为什么反复数次之后就可以成功呢?我猜测,大概是因为订阅的数量接近服务端的阈值,一方面收到的部分订阅响应后系统内时延增加了,另一方面网络传输速度不稳定,反复数次之后,某次不会超过这个阈值,结果就成功了。

我改成每订阅1000个合约之后暂停1秒,就再也没出现上述问题:

void subscribeAll(CBaseMd* pBaseMd, TMSubscribeVectorType& vSubscribeVector)
{
	int nCount = 0;
    for (auto pVitalData : vSubscribeVector)
    {
    	pBaseMd->subscribe(pVitalData->ExchangeID, pVitalData->StandardID);
        if ((++nCount) > 1000)
        {
        	std::this_thread::sleep_for(std::chrono::seconds(1));
        	nCount = 0;
        }
    }
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值