为阻塞型函数调用添加超时机制

我们产品中的一个子模块在进行Oracle实时数据库查询时,常常因数据库性能波动或异常而被阻塞在OCI API的调用上,为此我们付出了“惨痛”的代价。说来说去还是我们的程序设计的不够完善,在此类阻塞型函数调用方面缺少微小粒度的超时机制。

调用阻塞多发生在I/O操作(磁盘、网络、低速设备)、第三方API调用等方面。对于文件/网络I/O操作,我们可利用在非阻塞文件描述符上select /poll的超时机制来替代针对阻塞型文件描述符的系统调用;但在第三方API方面,多数时候是无法用select/poll来进行超时的,我们可以选择 另外一种方法:利用setjmp和longjmp的非局部跳转机制来为特定阻塞调用添加超时机制。其原理大致是:利用定时器(alarm、setitimer)设置超时时间,在SIGALRM的handler中利用longjmp跳到阻塞型调用之前,达到超时跳出阻塞型函数调用的效果。同时这种方法通用性更好些。

这个机制实现起来并不难,但有些细节还是要考虑周全,否则很容易出错。我们的产品是需要运行在LinuxSolaris两个平台下的,因此机制的实现还要考虑移植性的问题。下面简要说说在实现这一机制过程中出现的一些问题与解决方法。

一、第一版

考虑到阻塞型函数的原型各不相同,且我们的产品中对阻塞调用有重试次数的要求,因此打算将这个机制包装成一个,大致是这个模样:

#define add_timeout_to_func(func, n, interval, ret, …) \…

其中func是函数名;n是重试的次数;interval是超时的时间,单位是秒;ret是函数成功调用后的返回值,若失败,也是这个宏的返回值。

我们可以像下面这样使用这个宏:

/* example.c */
int
main()
{
    #define MAXLINE 1024
    char line[MAXLINE];

    int ret = 0;
    int try_times = 3;
    int interval = 1000;
    add_timeout_to_func(read, try_times, interval, ret, STDIN_FILENO, line, MAXLINE);
    if (ret == E_CALL_TIMEOUT) {
        printf("invoke read timeouts for 3 times\n");
        return -1;
    } else if (ret == 0) {
        printf("invoke read ok\n");
        return 0;
    } else {
        printf("add_timeout_to_func error = %d\n", ret);
    }
}

add_timeout_to_func中为阻塞型函数添加的超时机制是利用setjmp/longjmp与信号的处理函数合作完成的。

/* timeout_wrapper.h */
 

#include <setjmp.h>
#include <stdarg.h>
#include <unistd.h>
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <errno.h>

extern volatile int invoke_count;
extern jmp_buf invoke_env;

void timeout_signal_handler(int sig);
typedef void (*sighandler_t)(int);
#define E_CALL_TIMEOUT (-9)

#define add_timeout_to_func(func, n, interval, ret, ...) \
    { \
        invoke_count = 0; \
        sighandler_t h = signal(SIGALRM, timeout_signal_handler); \
        if (h == SIG_ERR) { \
            ret = errno; \
            goto end; \
        }  \
\
        if (sigjmp(invoke_env) != 0) { \
            if (invoke_count >= n) { \
                ret = E_CALL_TIMEOUT; \
                goto err; \
            } \
        } \
\
        alarm(interval);\
        ret = func(__VA_ARGS__);\
        alarm(0); \
err:\
        signal(SIGALRM, h);\
end:\
        ;\
    }

/* timeout_wrapper.c */
#include "timeout_wrapper.h"

volatile int invoke_count = 0;
jmp_buf invoke_env;

void
timeout_signal_handler(int sig)
{
    invoke_count++;
    longjmp(invoke_env, 1);
}

编译运行这个程序,分别在Solaris、Linux下运行,遗憾的是两个平台下都以失败告终。

先说一下在Linux下的情况。在Linux下,程序居然不响应第二次SIGALRM信号了。通过strace也可以看出,当alarm被第二次调用后, 系统便阻塞在了read上,没有实现为read增加超时机制的目的。原因何在呢?我在《The Linux Programming Interface》一书中找到了原因。原因大致是这样的,我们按照代码的执行流程来分析:

* add_timeout_to_func宏首先设置了信号的handler,保存了env信息(setjmp),调用alarm设置定时器,然后阻塞在read调用上;
* 1s后,定时器信号SIGALRM产生,中断发生,代码进入信号处理程序,即timeout_signal_handler; Linux上的实现是当进入处理程序时,内核会自动屏蔽对应的信号(SIGALRM)以及此时act.sa_mask字段中的所有信号;在离开 handler后,内核取消这些信号的屏蔽。
* 问题在于我们是通过longjmp调用离开handler的,longjmp对应的invoke_env是否在setjmp时保存了这些被屏蔽的信号呢? 答案是:在Linux上没有。这样longjmp跳到setjmp后也就无法恢复对SIGALRM的屏蔽;当再次产生SIGALRM信号时,程序将无法处 理,也就一直阻塞在read调用上了。

解决方法:将setjmp/longjmp替换为sigsetjmp和siglongjmp,后面这组调用在sigsetjmp时保存了屏蔽信号,这样在 siglongjmp返回时可以恢复到handler之前的信号屏蔽集合,也就是说SIGALRM恢复自由了。在Solaris 下,setjmp/longjmp是可以恢复被屏蔽的信号的。

再说说在Solaris下的情况。在Solaris下,程序在第二次SIGALRM到来之际,居然退出了,终端上显示:“闹钟信号”。这是因为在 Solaris下,通过signal函数设置信号的处理handler仅是一次性的。在应对完一次信号处理后,信号的handler被自动恢复到之前的处 理策略设置,对于SIGALRM来说,也就是程序退出。解决办法:通过多次调用signal设置handler或通过sigaction来长效设置 handler。考虑到移植性和简单性,我们选择了sigaction。在Linux平台下,signal函数底层就是用sigaction实现的,是简洁版的sigaction,因此它的设置不是一次性的,而是长效的。

二、第二版

综上问题的修改,我们有了第二版代码。

/* timeout_wrapper.h */

extern volatile int invoke_count;
extern sigjmp_buf invoke_env;

void timeout_signal_handler(int sig);
typedef void sigfunc(int sig);
sigfunc *my_signal(int signo, sigfunc* func);
#define E_CALL_TIMEOUT (-9)

#define add_timeout_to_func(func, n, interval, ret, …) \
    { \
        invoke_count = 0; \
        sigfunc *sf = my_signal(SIGALRM, timeout_signal_handler); \
        if (sf == SIG_ERR) { \
            ret = errno; \
            goto end; \
        }  \
\
        if (sigsetjmp(invoke_env, SIGALRM) != 0) { \
            if (invoke_count >= n) { \
                ret = E_CALL_TIMEOUT; \
                goto err; \
            } \
        } \
\
        alarm(interval); \
        ret = func(__VA_ARGS__);\
        alarm(0); \
err:\
        my_signal(SIGALRM, sf); \
end:\
        ;\
    }

/* timeout_wrapper.c */

volatile int invoke_count = 0;
sigjmp_buf invoke_env;

void
timeout_signal_handler(int sig)
{
    invoke_count++;
    siglongjmp(invoke_env, 1);
}

sigfunc *
my_signal(int signo, sigfunc *func)
{
    struct sigaction act, oact;

    act.sa_handler = func;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    if (signo == SIGALRM) {
#ifdef SA_INTERRUPT
        act.sa_flags |= SA_INTERRUPT;
#endif
    } else {
#ifdef SA_RESTART
        act.sa_flags |= SA_RESTART;
#endif
    }
    if (sigaction(signo, &act, &oact) < 0)
        return SIG_ERR;
    return oact.sa_handler;
}

这里从《Unix高级环境编程》中借了一段代码,就是那段my_signal的实现。这样修改后,程序在Linux和Solaris下工作都蛮好的。但目前唯一的缺点就是超时时间粒度太大,alarm仅支持秒级定时器,我们至少要支持毫秒级,接下来我们要换掉alarm。

三、第三版

setitimer与alarm是同出一门,共享一个定时器的。不同的是setitimer可以支持到微秒级的粒度,因此我们就用setitimer替换alarm,第三版仅改动了add_timeout_to_func这个宏:

#define add_timeout_to_func(func, n, interval, ret, …) \
    { \
        invoke_count = 0; \
        sigfunc *sf = my_signal(SIGALRM, timeout_signal_handler); \
        if (sf == SIG_ERR) { \
            ret = errno; \
            goto end; \
        }  \
\
        if (sigsetjmp(invoke_env, SIGALRM) != 0) { \
            if (invoke_count >= n) { \
                ret = E_CALL_TIMEOUT; \
                goto err; \
            } \
        } \
\
        struct itimerval tick;  \
        struct itimerval oldtick;  \
        tick.it_value.tv_sec = interval/1000; \
        tick.it_value.tv_usec = (interval%1000) * 1000; \
        tick.it_interval.tv_sec = interval/1000; \
        tick.it_interval.tv_usec = (interval%1000) * 1000; \
\
        if (setitimer(ITIMER_REAL, &tick, &oldtick) < 0) { \
            ret = errno; \
            goto err; \
        } \
\
        ret = func(__VA_ARGS__);\
        setitimer(ITIMER_REAL, &oldtick, NULL); \
err:\
        my_signal(SIGALRM, sf); \
end:\
        ;\
    }

至此,一个为阻塞型函数调用添加的超时机制的雏形基本实现完毕了,但要放在产品代码里还需要更细致的打磨。至少目前只是在单进程单线程中跑过,而且要求每个函数中只能调用add_timeout_to_func一次,否则就会有编译错误。

以上完整代码我都放到github上的experiments repository中了,有兴趣的朋友可以下载细看。

关于编程语言学习的一些体会

Learn at least one new language every year.
                                              — Andy Hunt and Dave Thomas

自己一直是“每年学习一门新语言”的忠实拥趸,曾先后认真地学习了HaskellCommon LispPythonGo等语言,对PrologScalaErlangLuaPHP也有一定了解。但几年下来,只有Python一门语言算 是真正被留在我的大脑里,用在了工作中。其他那几门语言留下来的只是一些思想了。这似乎符合了Andy Hunt和Dave Thomas在《程序员修炼之道》中对于这一实践目的的阐述:“学会用多种方式解决问题,扩展我们的视野,避免思路僵化和停滞不前”^_^。

即便是残存的思想,其实也并不深刻。要真正会运用新思维并非那么简单。一门编程语言从入门到精通,至少要经历学语法、做实践、用idioms(写出地道的代码)三个阶段。这让我深刻的感悟到:不以使用为目的的语言学习,都是在浪费生命

有精力多学习些语言自然很好,我迫切期待能拥有一个像“七龙珠”中孙悟空那样的“精神时光屋”呢。但现实中,人的精力是有限的,而我们要面对的计算机科学领域中的知识、技能以及问题却似乎是无限的。因此在“每年至少学习一门新语言”这一实践上,建议不要过于教条。 从编程语言自身来看,范型(Paradigm)是影响语言思维差异的主要因素,而编程语言的范型有限,主流的也就那么几种:命令式(过程式)、函数式、逻 辑式、面向对象等。每种范型的背后都有几种、十几种甚至几十种语言,我们其实没有必要都去学。从拓展视野的角度去说,从每种主流范式中找到一两门典型的语 言去学习就可以了。比如命令式的,我们可以选择C;函数式我们选择Haskell;逻辑式的选择Prolog;面向对象的选择Java等。

即便是从每个范型中挑出一门,你要付出的精力依旧不少,我们还要考虑其实用性:要以使用为目的。如果能将其用在工作中,天天与你相伴,被他人接受,自然最 好;退而求其次,你能找到一两个开源项目,并参与其中也是可以的,至少可以让你保持手热;如果这两点都无法做到,仅仅是凭借个人的热情与坚持,那是不会持 久的,若干时间后,你就会对其生疏,可能连基本的"Hello World"语法都记不得了。不过这个年头,思想也不能不要。在有剩余精力的前提下,挑选些牛人们极力“鼓吹”的语言,吸收一下其思想精华,说不定哪天就 能用得上,让自己和大家都感觉你很NB,抬高一下自己的身价^_^。记住:编程语言也是要拼爹的系出名门的语言(诸如Go、Dart等)自然得到更多的青睐、使用和推广,出位的几率也就高出许多,尤其是在目前新编程语言百花齐放的阶段。因此在选择有思想的新语言时,最好在这些名门之后中做优选。

这个时代喜欢“专家”,因此我们在一两门语言上务必要做到“精专”,这是会给你带来黄油和面包的语言。要专到什么程度呢?我有一个同事,什么问题都用C解决。他甚至为此写了个不小的基础框架,所有业务问题的Code放在框架中被回调即可,即便是这个问题用Python实现只需几行代码。

计算机科学的研究核心是什么?我想肯定不是编程语言,就好比社会科学研究的核心不是人类语言一样。我比较欣赏这样的观点:作为程序员而言,最重要的是去创造,而不是研究。我们应更多的利用已经掌握的语言解决现实中的问题。做 编程语言研究的人可能要了解各种语言的特点与实现方式,但对于大多数的程序员来说,其实我们只需要关注问题域:做底层平台开发的,关注机器模型、通信原理 以及OS原理和实现细节;做算法的,很荣幸,那才是正统的程序设计的核心;前端攻城师则更多关注用户的体验。而在这些解决实际问题的过程中,我们更多采用 的是“制式”的编程语言。即做平台开发的,一般用C,C++等系统编程语言,更多的考虑的是性能;做前端开发的,PHP/JavaScript不可或缺。 我们要考虑的是如何利用这些制式的编程语言去解决问题,而在这些制式语言上,我们要做到精通。

从新兴语言中借鉴新思想,然后在旧语言中实现新语言的特性,其实更多是在旧语言中实现了某 种语法糖,你爱吃,不代表其他人也理解也爱吃,还容易被人误认为是“炫技”。如果你是技术负责人,且经过评估,新语言十分适合这个问题域,那莫不入直接引 入这门语言,让大家都能使用到这门语言的新思想、新特性。

辩证的说,任何一种编程语言都有其利与弊,比如Haskell,纯函数式语言,变量不能改变,无状态,对并行处理具有天然的适应性,但在处理基本IO时却要编写难于理解的monad;而在命令式语言中,这种IO处理简直简单的不得了。

关于函数式语言,个人感觉未来若干年内仍难以大行其道,建议还是跟上命令式语言的演化主线吧。

跨越问题域学习语言,通常收获不大。一个做平台服务端,用惯了C的资深程序员,让他去学PHP写前端代码,估计是无法迸发出任何火花的。

以上是自己这些年关于编程语言学习的一些体会,比较零散,但希望能有帮助。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats