循环变量引起的死循环

绝大部分编程语言都提供类似forwhile等关键词的循环语句。这可能是我们使用得最频繁的语句之一。但是使用不当,也可能造成死循环,并且某些死循环是非常难以察觉和重现的。比如接下来会说到的情况。 最近在阅读Skynet的源代码(Skynet是由云风编写的一套游戏服务器框架,采用Actor模式),在/lualib-src/lua-bson.c中有如下函数:

static inline void 
bson_reserve(struct bson *b, int sz) {
    if (b->size + sz <= b->cap)
        return;
    do { 
        b->cap *= 2;
    } while (b->cap <= b->size + sz); 

    if (b->ptr == b->buffer) {
        b->ptr = malloc(b->cap);
        memcpy(b->ptr, b->buffer, b->size);
    } else {
        b->ptr = realloc(b->ptr, b->cap);
    }
}

这个函数的目的是,根据传入的sz增长b指向的内存大小。首先这个函数如果是用作外部接口使用,漏洞非常多。即使仅仅用作内部调用也有很大风险。

  • 在使用malloc()分配内存时,并未做返回值判断。如果malloc()分配内存失败,返回NULL,此时程序逻辑就已陷入异常,再直接使用memecpy()拷贝内容到b->ptr(此时是NULL),程序崩溃。当然一般不会出现分配内存失败的情况,但确实写得很不严谨。
  • 并未对传入参数做前置判断。b为NULL, sz为负数等情况均未考虑。作为我个人的编程习惯,不管函数是外部接口或是内部方法,都会进行严格的参数检查,这样会帮助我在开发阶段就发现和避免了许多的Bug。除非是非常核心的代码——多一行if判断都对性能有影响的代码。但既然对性能要求如此严苛,何不换用更底层的编程语言来实现呢?特别是当我们的代码运行在极端情况下时——航天器、心脏起搏器里,我们必须要对异常甚至不可能出现的错误做好Plan B,因为没有人敢保证复杂项目中的代码就没有Bug。

最后我们回到文章的主题。看函数中的do{}while()循环语句,大意是当b的内存容量翻倍后,如果b的内存容量仍然小于等于新的内存大小,则b的内存容量继续翻倍,直到b的容量可以满足新的内存大小,则退出循环。但是,这里的do{}while()循环可能会陷入死循环。当b->cap大于INT_MAX/2时,b->cap *= 2;执行结束后,b->cap整数越界(此时一定会有new b->cap小于old b->cap),则while()中的条件判断一定成立。到此逻辑已经出现错误。更坏的情况是,某些情况下甚至会导致死循环。 接下来,我们构造一个更加简单的例子来说明,循环变量可能会如何引起死循环。写出这样的代码的几率一定不会低。

#include <stdio.h>
#include <stdint.h>

int main() {
    uint8_t max_uint8 = (uint8_t)~0;// 255
    uint8_t i;
    for (i = 0; i < max_uint8; i += 10) {
        printf("i:%u\n", i);
    }
    return 0;
}

你可以在在你的PC上执行此代码,但请注意此程序会陷入死循环。在终端中请使用Ctrl+c强制终止此程序。引起死循环的原因就是整数越界。当i等于250时,i满足for循环的执行条件,可继续执行。执行i += 10,此时i越界了变成了4,是满足for循环的执行条件的,循环得以继续执行,但已经出错了。 那么我们如何避免因为循环变量越界而导致的错误甚至死循环呢?既然是因为在边界条件判断时出了错,那么我们给其限定执行次数不就行了吗,保证在限定的执行次数内一定是不会发生循环变量越界的,就像strncpy()之于strcpy()。那么这个执行次数应该如何确定呢?我们以上面的死循环代码为例,对其进行改进:

#include <stdio.h>
#include <stdint.h>

int main() {
    uint8_t max_uint8 = (uint8_t)~0;// 255
    uint8_t i;
    uint8_t max_count = max_uint8 / 10; 
    for (i = 0; i <= max_count; i++) {
        printf("i:%u\n", i * 10);
    }
    return 0;
}

我们先求得原来的for循环在不发生循环变量越界的前提下,最多能循环的次数。这样就修改为了我们最为常见的循环变量自增1的for循环,就不会在出现因循环变量越界而导致错误和死循环了。这个例子非常简单,那如果遇到bson_reserve()函数中乘法递增的循环变量又该如何确定其最大执行次数呢?我们构造一个例子:

#include <stdio.h>                                                                      
#include <stdint.h>

int main() {
    uint8_t max_uint8 = (uint8_t)~0;// 255
    uint8_t capacity = 5;
    do {
        if (max_uint8 / capacity < 2) {
            printf("capacity:%u即将越界,请做异常处理\n", capacity);
            break;
        }
        printf("capacity:%u\n", capacity);
        capacity *= 2;
    } while (capacity <= 200);
    return 0;
}

在do{}while()循环中,去检测当前capacity是否能够进行下一次的翻倍,如果不能则报告错误或者做异常处理,当然例子里只是简单的跳出循环而已。其实上个例子同样可以使用这样的判断方式来界定循环变量会否越界,只是我一开始思考的思路是这样的——如何安全地确定其最大执行次数。现在我们将其修改如下:

#include <stdio.h>
#include <stdint.h>

int main() {
    uint8_t max_uint8 = (uint8_t)~0;// 255
    uint8_t i;
    for (i = 0; i < max_uint8; i += 10) {
        printf("i:%u\n", i); 
        // 检测循环变量下一次自增是否能成功
        if (max_uint8 - i < 10) {                                                       
            printf("再次循环将越界,请做异常处理\n");
            break;
        }   
    }
    return 0;
}

关键在于如何安全地提前判断下一次循环是否会发生越界,如果会就做异常处理,否则继续循环。记住这个陷阱,写出更加健壮的代码。