共计 3340 个字符,预计需要花费 9 分钟才能阅读完成。
本文同步公布于字节话云公众号。
背景
前段时间老的 MacBook 到了退休的年纪,听了好友的安利换了基于 Apple M1 的 MacBook,在运行一个我的项目时发现本来能失常执行的工作在新笔记本上就始终是进行中,由此开展了一段 Bug 调试之旅。
初步摸索
有如下几点信息:
- Python 版本为
2.7.10 +
,eventlet 版本为v0.21.0
。 - 造成这个问题的间接起因是代码中应用了
eventlet.sleep
,一旦以后协程执行到了 sleep 语句,eventlet
在切出协程后就不会再切回来 - 应用 py-spy 察看过程发现,看到形如下方的调用信息:
%Own %Total OwnTime TotalTime Function (filename:line)
0.00% 0.00% 0.010s 0.010s run (eventlet/hubs/hub.py:334)
能够初步断定问题出在 sleep 上,因为我的项目逻辑过于简单不便调试,不如写个最小化测试代码来验证猜测。
复现问题
既然狐疑问题出在 sleep 上,并且认为 eventlet
在 sleep 后不会切换协程,无妨在一个协程池中开两个协程,每个协程一直输入和 sleep,那么就有如下代码:
import eventlet
def echo(name):
while True:
print(name)
eventlet.sleep(2)
def main():
p = eventlet.GreenPool()
p.spawn(echo, 'foo')
p.spawn(echo, 'bar')
p.waitall()
if __name__ == '__main__':
main()
运行后,在顺次输入 foo
和 bar
后,就不再持续输入了。
调试和剖析
既然输入了 foo
和 bar
,就阐明在执行第一个 echo
协程输入 foo
并进入 sleep 后并没阻塞 eventlet
的调度,所以 eventlet
能力调度到第二个 echo
协程输入 bar
。然而为什么两个协程 sleep 后都不会再次被调度了?既然后面通过 py-spy 监控过程调用栈发现始终在调用 eventlet/hubs/hub.py
,无妨查看并调试此文件中的 run
逻辑。
def run(self, *a, **kw):
"""Run the runloop until abort is called."""
# accept and discard variable arguments because they will be
# supplied if other greenlets have run and exited before the
# hub's greenlet gets a chance to run
if self.running:
raise RuntimeError("Already running!")
try:
self.running = True
self.stopping = False
while not self.stopping:
while self.closed:
# We ditch all of these first.
self.close_one()
self.prepare_timers()
if self.debug_blocking:
self.block_detect_pre()
self.fire_timers(self.clock())
if self.debug_blocking:
self.block_detect_post()
self.prepare_timers()
wakeup_when = self.sleep_until()
if wakeup_when is None:
sleep_time = self.default_sleep()
else:
sleep_time = wakeup_when - self.clock()
if sleep_time > 0:
self.wait(sleep_time)
else:
self.wait(0)
else:
self.timers_canceled = 0
del self.timers[:]
del self.next_timers[:]
finally:
self.running = False
self.stopping = False
在这段代码中,咱们能够看到对 sleep 的相干解决(第 23\~31 行),获取协程要被唤醒的工夫并减去以后工夫,如果超过 0 阐明还须要等一段时间,反之就唤醒。实测发现 self.clock()
返回的时钟数值有问题。
此处的 clock
也就是 eventlet/support/monotonic.py
中的 monotonic()
函数,用于返回枯燥递增的时钟数(每加 1 即通过 1 秒)。此函数在 macOS 上,相干实现如下:
if sys.platform == 'darwin': # OS X, iOS
# See Technical Q&A QA1398 of the Mac Developer Library:
# <https://developer.apple.com/library/mac/qa/qa1398/>
libc = ctypes.CDLL('/usr/lib/libc.dylib', use_errno=True)
class mach_timebase_info_data_t(ctypes.Structure):
"""System timebase info. Defined in <mach/mach_time.h>."""
_fields_ = (('numer', ctypes.c_uint32),
('denom', ctypes.c_uint32))
mach_absolute_time = libc.mach_absolute_time
mach_absolute_time.restype = ctypes.c_uint64
timebase = mach_timebase_info_data_t()
libc.mach_timebase_info(ctypes.byref(timebase))
ticks_per_second = timebase.numer / timebase.denom * 1.0e9
def monotonic():
"""Monotonic clock, cannot go backward."""
t = mach_absolute_time()
return mach_absolute_time() / ticks_per_second
monotonic()
函数调用 mach/mach_time.h
的 mach_absolute_time()
零碎函数并通过系数解决失去最终的时钟数,这里的解决形式和 mach_absolute_time() / timebase.numer * timebase.denom / 1.0e9
等价,然而这个算法是谬误的。
正确的表达式应该是 mach_absolute_time() * timebase.numer / timebase.denom / 1.0e9
,可参照 monotonic.py 的实现。将此实现批改后,再运行测试代码,eventlet
就可能失常进行 sleep 和调度了。
为什么谬误的逻辑在 Intel 芯片上运行失常?
mach_timebase_info_data_t
中的 numer
和 denom
别离是时钟数缩放因子的分子和分母,在 Intel 芯片上固定都是 1。这也就意味着 number / denom
和 denom / number
的后果一样,换句话说,即便缩放因子表达式写反了,计算出的后果也不会有所变动。
但在 M1 芯片上,分子和分母并不相等,numer
是大于 denom
的,应用谬误的缩放因子 denom / number
会导致计算出的后果小于正确值,也就意味着实在工夫过来 1 秒时,计算出的工夫只过来 0.000x 秒,那么 eventlet
就会认为还没达到须要唤醒的工夫而持续期待,从而造成卡死的假象。
对于此问题的修复,eventlet
在 v0.24.0
版本中引入了第三方库 monotonic.py 解决了这个问题。
总结
应用 Apple M1 开发程序的真的都是壮士,因为 CPU 架构和 Intel 不同,极有可能呈现非预期的问题。一旦呈现问题,基本思路就是先察看景象,应用诸如 py-spy 的工具去进一步看看过程在做什么,而后尝试复现和调试,逐渐从下层到底层剖析代码,定位问题。