decorator

Python decorator 库和 gevent 冲突的情况

去年写了两篇分析 decorator 的 blog:

在线上项目里一直也没有用 pypi 的 decorator 库去替换自己的实现,最近替换后撞上了一些问题,整个问题追查过程也各种艰辛,记录一下

我们线上用的 flask 0.11.1 + celery 3.1.25 + gevent 1.2.2 在跑任务队列,broken 用的阿里云 redis 2.8,某个周末突然发现 worker 工作不正常,有大量的 db 连接报错,在简短排查后没找到原因,重启整个服务,暂时跳过这个问题,其实后面还在有其他错误,但也没有解决思路

等周一到公司,团队的人对比了下问题现场和思路,只能发现是 celery worker 会莫名卡死,跑到我们的 k8s 集群上看,对应的 pod 是 running,但是 flower 上看监控是 offline,直接切进 pod 看日志,好几个最后都断在 requests 发请求出去的地方,N 脸懵逼,我们又没升级系统又没动依赖,怎么就会冒出来这个问题

既然最后死在 requests 那,虽然看后面的 Changelog 没发现有跟我们直接习惯的,但还是先把 requests 版本从 2.19.0 升到 2.21.0,并把依赖的库也同步升级。然而并没有解决问题,还是一样的死,还是一样的错

后面多看了几个问题现场,只能协助定位还可能会卡死在 db 读写的地方,这个时候开始怀疑 gevent,因为这货帮忙做了 IO 异步优化,强行协程化,而且这个版本也有点老,期间看也有一些可能相关的 Issue,升级 1.4.0 后发现服务起不来,查了下看需要把 celery 升级到 4.x,但 4.x 的 celery 参数序列化从 pickle 改成 json,还要改挺多代码的,只能先搁置

后面还查了下 celery 的版本问题,我们用的 3.x 是有点老了,但 4.x 那个参数结构该动那个有大所以一直没动,另外也查用 redis 做 broken 的锅,没有任何有效的相关信息

出问题前我在代码里替换了一版 lock 的实现,以及 lock_decorator 的实现,但是往这边怀疑也不对,因为有的出错的地方并没有用到锁,而且看日志和报错也跟锁没有关系

等过了一周,第二个周末的时候开脑洞说好像现在有问题的方法都有修饰器包着,而且这些修饰器都用了 decorator 库,也许这里可能有问题?我们的 decorator 用的是 4.3.0,最新的版本是 4.3.2,大致看了下 Changelog,似乎提到有修复协程问题,但是是 python 3.5 的,不过反正不用改代码,先升了再说

升完后问题真的消失了,那么就来看看到底哪里可能有问题吧。对比期间的变更记录 https://github.com/micheles/decorator/compare/4.3.0…4.3.2,除了一些文档变化和对 py3.5 的协程修复,比较值得怀疑的是 https://github.com/micheles/decorator/commit/eb890d98739196b83f1ecb5cb7bcfe9739a9502c 这个提交,decorator 的原理是在内存里新建一个虚拟文件,把要修饰的方法的相关属性写到这个虚拟文件里,然后通过虚拟文件里的同名方法调装饰器逻辑,装饰器方法里再去调到最终的方法,而这个虚拟文件如果只用 itertools.count(),在 gevent 的协程调度下可能会重名,然后整个代码体系就崩了

Python decorator 库

上个月关于 Python 的 Decorator 写过一篇 Python 多层 decorator 内获取原始函数参数字典,后来熊提醒这种比较通用的东西应该都会有现成的库,搜了下果然有 个库就叫 Decorator

相关的项目地址在

看了下源码,他比之前我的做法更进一步,直接把要修饰的原方法的名字和参数,都扫代码解析出来,再到一个解析器的临时文件里原样写一个新的方法,并把相关需要复制的参数属性等都直接复制给这个新方法,这个新方法再调修饰方法,就可以更完美的实现对外界透明

例如这样的代码

def foo(x, y=2, *args, **kw):
  print(x)

def dec(func):
  def wrapper(*args, **kw):
    print("called %s with %s, %s" % (func.__name__, args, kw))
    return func(*args, **kw)
  return wrapper

foo = dec(foo)

在经过了 @decorator 后会变成

_func = foo
_caller = wrapper

foo = def foo(x, y=2, *args, **kw):
  return _caller(_func)(x, y, *args, **kw)

只有一些涉及到 func_code 内存地址的地方才可以发现不一样

果然还是有库用库,绝大部分情况人家还是实现得更好,就是原理能不能看懂了。自己折腾的好处是正向推过去,坑和原理都比较了解,而反向看别人的代码,有很多奇妙的地方先想明白为什么要考虑这个情况,以及这个情况为什么要这样处理,都需要花很久

最后小吐槽一下,在 decorator 库里,如果是 py3,replace('return', 'return await') 真的没问题?如果有人逗比写了个 returnVal = xxx 不就崩了

Python 多层 decorator 内获取原始函数参数字典

0. 在 decorator 里获取原始函数的参数值

项目里做了一个通用锁,使用 decorator 来方便的包住某些需要限制并发的函数。因为并发不是函数级别的,而是根据参数来限制,所以需要把参数传到通用锁的 decorator 里,代码大致如下

def lock_decorator(key=None):
    def _lock_func(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # TODO: get lock_key
            lock_key = kwargs.get(key, '')
            with LockContext(key=lock_key):
                return func(*args, **kwargs)
        return wrapper
    return _lock_func

@lock_decorator(key='uid')
def apply_recharge(uid, amount):
    # ...

考虑到函数调用不一定都是带着参数名的,就是说调用时不一定所有参数都会进 **kwargs,那就需要从 **args 里面按参数名捞参数

怎么能知道原函数的参数名列表,翻各种手册的得知可以用 inspect.getargspec(func) 来搞到,那么上面的 TODO 部分就可以改写如下

            args_name = inspect.getargspec(func)[0]
            key_index = args_name.index(key)
            if len(args) > key_index:
                lock_key = args[key_index]
            else:
                lock_key = kwargs.get(key, '')

自此,一切都很美好

1. 在 decorator 里获取原始函数的调用参数字典

项目里又做了个通用的 Logger,也做成 decorator 往目标函数一套,就可以打印出调用时的入参和结果,大致如下

def log_decorator():
    def _log_func(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # TODO: get full args
            print('call func [{}] with args [{}] and kwargs [{}]'.format(func.__name__, args, kwargs))
            ret = func(*args, **kwargs)
            print('func [{}] return [{}]'.format(func.__name__, ret))
            return ret
        return wrapper
    return _log_func

@log_decorator()
def apply_recharge(uid, amount):
    # ...

看起来也还好,不过因为函数可能带默认参数,而且也希望看到 **args 到底传到哪个参数上,还是希望把所有参数按 Key-Value 的形式打印出来,跟处理通用锁一样,用 inspect.getargspec(func) 把参数名和默认值都摸出来,再考虑一下可变参数的情况,对上面的 TODO 部分改写如下

            args_name, _, _, func_defaults = inspect.getargspec(func)
            parsed_kwargs = dict()
            # default args
            default_args = dict()
            default_start = len(args_name, func_defaults)
            for idx, d in enumerate(func_defaults):
                default_args[args_name[default_start + idx]] = d
            parsed_kwargs.update(default_args)
            # args with name
            varargs_start = len(args_name)
            for idx, a in enumerate(args[:varargs_start]):
                parsed_kwargs[args_name[idx]] = a
            # varargs
            if len(args) > varargs_start:
                parsed_kwargs['varargs'] = args[varargs_start:]
            # kwargs
            parsed_kwargs.update(kwargs)
            print('call func [{}] with args [{}]'.format(func.__name__, parsed_kwargs))

到这里,还是很美好

2. 多层 decorator 怎么拿到最原始函数的参数表

注意到上面两个例子里,apply_recharge 都只套了一个 decorator,如果两个一起用会发生什么?

根据 PEP318 里对 decorator 的定义

@dec2
@dec1
def func(arg1, arg2, ...):
    pass

等价于

def func(arg1, arg2, ...):
    pass
func = dec2(dec1(func))

这里就出问题了,dec2 拿到的传入函数其实是 dec1 而不是 func。不过在把 lock_decoratorlog_decorator 混用时,不管谁写前面,func.__name__ 都是原始的函数名,说明也还是有神器的地方做了穿透,但是 inspect.getargspec 又拿不到最底层函数的参数表,导致不管谁前谁后,都有问题

注意到每个 decorator 构建的时候都又封了一个 @functools.wraps(func),这个是干嘛的呢?以前都是无脑用,也没想过为啥要包一层这个,去掉会怎样?

去掉这个 @functools.wraps(func) 后,inspect.getargspec 还是一样的只能拿到最近一层的信息,而之前本来可以拿到底层的 func.__name__ 也变成最近一层的函数名了,说明这里做了穿透。那么去看看代码吧

# functools.py

from _functools import partial, reduce

# update_wrapper() and wraps() are tools to help write
# wrapper functions that can handle naive introspection

WRAPPER_ASSIGNMENTS = ('__module__', '__name__', '__doc__')
WRAPPER_UPDATES = ('__dict__',)
def update_wrapper(wrapper,
                   wrapped,
                   assigned = WRAPPER_ASSIGNMENTS,
                   updated = WRAPPER_UPDATES):
    """Update a wrapper function to look like the wrapped function

       wrapper is the function to be updated
       wrapped is the original function
       assigned is a tuple naming the attributes assigned directly
       from the wrapped function to the wrapper function (defaults to
       functools.WRAPPER_ASSIGNMENTS)
       updated is a tuple naming the attributes of the wrapper that
       are updated with the corresponding attribute from the wrapped
       function (defaults to functools.WRAPPER_UPDATES)
    """
    for attr in assigned:
        setattr(wrapper, attr, getattr(wrapped, attr))
    for attr in updated:
        getattr(wrapper, attr).update(getattr(wrapped, attr, {}))
    # Return the wrapper so this can be used as a decorator via partial()
    return wrapper

def wraps(wrapped,
          assigned = WRAPPER_ASSIGNMENTS,
          updated = WRAPPER_UPDATES):
    """Decorator factory to apply update_wrapper() to a wrapper function

       Returns a decorator that invokes update_wrapper() with the decorated
       function as the wrapper argument and the arguments to wraps() as the
       remaining arguments. Default arguments are as for update_wrapper().
       This is a convenience function to simplify applying partial() to
       update_wrapper().
    """
    return partial(update_wrapper, wrapped=wrapped,
                   assigned=assigned, updated=updated)

原来就是这里耍花样了,把底层函数的 ('__module__', '__name__', '__doc__') 都赋给了 decorator 封起来的这一层,欺骗更上层用 __name__ 去判断时就当我是底层

那我也学这个,把 inspect.getargspec 的地方也处理下不就完了,去看看这个地方是怎么拿参数表的

# inspect.py

def getargspec(func):
    """Get the names and default values of a function's arguments.

    A tuple of four things is returned: (args, varargs, varkw, defaults).
    'args' is a list of the argument names (it may contain nested lists).
    'varargs' and 'varkw' are the names of the * and ** arguments or None.
    'defaults' is an n-tuple of the default values of the last n arguments.
    """

    if ismethod(func):
        func = func.im_func
    if not isfunction(func):
        raise TypeError('{!r} is not a Python function'.format(func))
    args, varargs, varkw = getargs(func.func_code)
    return ArgSpec(args, varargs, varkw, func.func_defaults)

看了下用到了 func.func_codefunc.func_defaults,按 Python 官方文档 https://docs.python.org/2/library/inspect.html 的解释,func_code 是运行时的字节码,从这里面捞参数表果然可行,那是不是我把这两个属性也传递上去就行了呢?改用自己的 wraps 如下

WRAPPER_ASSIGNMENTS = functools.WRAPPER_ASSGNMENTS + ('func_code', 'func_defaults')
def my_wraps(wrapped,
             assigned = WRAPPER_ASSIGNMENTS,
             updated = WRAPPER_UPDATES):
    return _functool.partial(functools.update_wrapper, wrapped=wrapped,
                             assigned=assigned, updated=updated)

运行时报错,看了下错误提示,func_code 不可覆盖,这也对,都是运行时的字节码了,这个覆盖掉那包的这层 decorator 到底还有没有自己的逻辑部分

还是自己动手丰衣足食,既然 func_code 不可覆盖,我自己另外弄一个总可以了吧,而且当前需求是拿到参数表和默认参数,那就直接解出来穿透,也懒得最后再解一次。修改 my_wraps 如下

WRAPPER_ASSIGNMENTS = functools.WRAPPER_ASSIGNMENTS + ('__func_args_name__', '__func_default_args__')

def my_wraps(wrapped,
             assigned = WRAPPER_ASSIGNMENTS,
             updated = functools.WRAPPER_UPDATES):
    if getattr(wrapped, '__func_args_name__', None) is None:
        setattr(wrapped, '__func_args_name__', inspect.getargs(wrapped.func_code)[0])
        func_defaults = getattr(wrapped, 'func_defaults') or ()
        default_args = dict()
        default_start = len(wrapped.__func_args_name__) - len(func_defaults)
        for idx, d in enumerate(func_defaults):
            default_args[wrapped.__func_args_name__[default_start + idx]] = d
        setattr(wrapped, '__func_default_args__', default_args)
    return _functools.partial(functools.update_wrapper, wrapped=wrapped,
                              assigned=assigned, updated=updated)

同时在运行时解参数表,也用一个通用函数来实现

def parse_func(func, *args, **kwargs):
    parsed_kwargs = dict()
    # default args
    parsed_kwargs.update(func.__func_default_args__)
    # args with name
    varargs_start = len(func.__func_args_name__)
    for idx, a in enumerate(args[:varargs_start]):
        parsed_kwargs[func.__func_args_name__[idx]] = a
    # varargs
    if len(args) > varargs_start:
        parsed_kwargs['varargs'] = args[varargs_start:]
    # kwargs
    parsed_kwargs.update(kwargs)

    return parsed_kwargs

这样在 lock_decoratorlog_decorator 里,用 my_wraps 来封装处理,同时在里面用 parse_func 来解析参数,就能拿到完整的参数表了

完整的测试代码见 https://gist.github.com/whusnoopy/9081544f7eaf4e9ceeaa9eba46ff28da