Tag Archives: Multitasking

可移植的静态线程局部存储修饰符

主流 C、C++ 编译器在主流平台下通常都通过语言扩展来支持静态线程局部存储,用户可以通过在静态变量前简单地添加一个修饰符使其被静态地添加到 TLS 索引中。然而,不同的编译器支持不同的修饰方式,有时为了可移植性,就需要类似下面这样的预编译指令,根据当前使用的编译器来决定如何定义修饰符。

#if defined(__GNUC__) || defined(__SUNPRO_C) || defined(__xlc__)
# define THREAD_LOCAL __thread
#elif defined(_MSC_VER) || defined(__BORLANDC__) || defined(__DMC__)
# define THREAD_LOCAL __declspec(thread)
#elif defined(__INTEL_COMPILER)
# ifdef _WIN32
#   define THREAD_LOCAL __declspec(thread)
# else
#   define THREAD_LOCAL __thread
# endif
#else
# error "Unsupported compiler."
#endif

之后用 static THREAD_LOCAL 修饰变量即可。

全局解释器锁

我们曾经多次提到过一个术语——全局解释器锁(Global Interpreter Lock,下简称 GIL)。GIL 是一个通用的术语,而 Ruby 1.9 的开发员似乎又赋予了 Ruby 1.9 的 GIL 另一个特有的名称——Giant VM Lock,硬翻为中文就是“巨型虚拟机锁”。

在官方和其它一些 C/C++ 的 Ruby 实现(如 Rubinius),以及其它有着相似线程模型的动态类型语言实现(如 CPython)中都存在着 GIL。顾名思义,它是被集成在解释器核心、为解释器量身打造的一个全局的互斥锁,用来保护解释器内部的共享资源。

虽然 CRuby 解释器的核心部分是线程安全的,但这并不代表本地扩展库提供的代码也是线程安全的(当然,编写扩展的人可以刻意地去写出线程安全的代码)。当我们使用标准库、第三方 Gem 时,我们很多时候都是在使用包含了线程不安全代码的本地扩展,而这些本地扩展多少要访问一些解释器核心的共享资源。一旦线程不安全代码与解释器内核在没有任何线程安全措施的情况下同时访问这些资源,就可能发生各式各样的多任务问题,尤其是竞争条件。这些问题使得我们的程序充斥着奇怪的八阿哥却又极其难以调试,所以我们迫切地需要一个措施来实现线程安全。

通常保证线程安全性有四种途径:

  1. 设计可重入的子程序
  2. 使用线程局部存储
  3. 仅使用原子操作
  4. 多任务同步(引入互斥锁)

然而,前三种途径并不能解决 Ruby 解释器的资源分配问题。解释器的共享资源对于一个解释器实例(解释器进程)来说具有全局性。既然解释器核心和本地扩展都需要变异全局静态的数据,那么即便解释器核心可重入,本地扩展仍然可以在并发时变异这些数据,使得解释器核心代码线程不安全。这也强调了一点:可重入并不意味着线程安全。由于涉及到共享资源,仅仅使用第二种途径的线程局部存储自然也是不够的。原子操作的定性和底层平台具体的(虚拟或物理)机器指令有关,对于一个需要跨平台的解释器实现来说,依赖原子操作而实现并发并不是一个好习惯。最后我们只剩下通过互斥锁设定一个内存屏障,建立临界区这个方案。

同步多任务也可分为细加锁(Fine-grained locking)和粗加锁(Coarse-grained locking)两种加锁方案。细加锁是一种高密度加锁,它在所有和共享资源有关的结构上分别加锁(这些锁相互独立);粗加锁则是一种低密度加锁,它使用一个被所有结构共享的全局锁。

假设某组织一幢办公大楼,有若干工作人员和若干工作室,工作人员需要按工作室的顺序进入各个工作室工作,而从当前工作室出来到进入下一个工作室之间的这段时间他们也有一些别的任务需要完成。细加锁,就好比这些工作人员一旦进入其中一个工作室就将其门锁住,直到自己工作结束,再解锁出门。这时可能有若干工作人员为了进入某个工作室而被迫等在门外,但也可能有若干工作人员正在完成不需要在工作室中进行的任务(即前文所说的从当前工作时出来到进入下一个工作室之间需要完成的任务)。在另一方面,粗加锁就好比工作室并没有锁,但整个办公大楼有一道大锁,一旦有一个工作人员进入办公大楼就锁住大楼入口,其它任何人都不得进入。每一个工作人员进入后可以自由工作 15 分钟,之后解锁走出办公大楼,让下一个工作人员进入并工作 15 分钟,如此反复。

这里的工作人员就相当于线程,而工作室则是有着共享资源的临界区,办公大楼好比是整个进程。显然,较之粗加锁,细加锁有更高的并发性,有更多的工作人员可以在同一时刻工作。然而,细加锁在工作人员每次出入工作室时都多了一道解锁、加锁的程序。试想该组织只有一个工作人员,他独自一人在办公大楼中自是通行无阻,也无人和他争夺工作室,又何须频繁加锁解锁?这在现实世界中是举手之劳,在某些语言实现(比如曾经依赖于引用计数的 CPython 垃圾回收器)中却会带来巨大的开销。因此,细加锁会降低单线程程序的执行效率,提高多线程程序的执行效率,而粗加锁反之。

想必看官业已猜到,粗加锁的这把锁,就是我们的话题——GIL。Ruby 语言的创始人 Matz 在他的 MRI 实现中使用的是 GIL 进行粗加锁。他之所以做出这个抉择,无非是为了不用考虑本地 Ruby 扩展的线程安全问题,以及提高单线程程序的执行效率。此举是否得当当然也是可商榷的。有些人宁愿在写扩展的时候自行考虑线程安全问题,也不愿为了代码简易而丢失效率。而从长远来看,程序迟早将全面步入并发计算领域,为了单线程执行效率而牺牲多线程执行效率的意义似乎越来越小了。

Ruby 1.8 内置的 Thread 类使用的是绿色线程,而并非本地线程。Ruby 1.8 的解释器通过模拟本地 OS 的排程机制实现了 Ruby 层线程之间的周期性切换。因此,自始至终每个 Ruby 解释器进程都只有一个真正的用于求值的本地线程(不包含解释器的辅助线程)。哪怕有再多的 CPU 内核,在 Ruby 1.8 中也绝不可能实现真正的并发。更甚的是,当你调用某个外部的耗时函数时(比如进行阻塞 Socket),由于调用线程是唯一的一个负责绿色线程调度的本地线程,整个解释器进程都会因之而阻塞,Ruby 层的绿色线程自然也会尽数僵死。出于这两点考虑,Ruby 1.9 的 Thread 类使用了本地线程。然而,由于 GIL 的存在,两个 Thread 的实例仍然不能真正的并发运行。既然如此,将绿色线程替换为本地线程的意义何在呢?

首先,绿色线程的调度是在用户空间中管理的,在很多情况下下效率不及由 OS 在内核空间中管理的本地线程;其次,本地线程可以解决解释器进程整体阻塞的问题。Ruby 1.9 默认将部分耗时运算(如 Bignum 的乘法)分配到了一个本地线程中运行,并在进行该运算时暂时释放 GIL,直到运算结束再重新收回 GIL。在 GIL 被释放的这段时间里,其它 Ruby 层的线程就可以运行,那么在多核 CPU 的环境下,即便很短暂,也实现了真正意义上的并发。在写本地 Ruby 扩展的时候,用户可以调用 rb_thread_blocking_region 这个函数来进行可能导致阻塞的操作,其内部其实就是封装了一个释放并收回 GIL 的过程。

为了科学严谨性,我们最后还要强调一个事实,那就是采用 GIL 只是一个实现细节而并非语言特性。比如,同样是 Ruby 语言,基于 Sun JVM 的 JRuby 就不需要 GIL,因为它在底层依赖于有着自己的独特线程模型的 Java 虚拟机。Sun JVM 并不依赖于 GIL 的保护,相反它依赖于细加锁,这和现代化的 GC、高效的 JIT 乃至于整个虚拟机的复杂度都是密切相关的,这也是为什么 Sun JVM 的性能能得到公众的认可。

纤程

Ruby 1.9 引入了纤程(Fiber)的概念。

提起纤程,我们自然而然地就想到了线程(Thread),而事实上当你对比了“纤”和“线”这两个字后,你大概就能猜到,纤程实际上是一种轻量级的线程了。轻量级在这里是指纤程的体积比线程小,更容易维护、调试,但也不具备一些线程才能提供的复杂功能。

在并行计算领域,我们使用线程来构建一个抢先式多任务模型;而纤程则是用于合作式多任务模型。

抢先式多任务模型,顾名思义,是指执行任务的对象的工作模式是抢先式的,工作调度完全由一个具有权利的、客观的存在进行。比如:三个兄弟抢着用电脑,谁也不让谁,但是当妈妈在家的时候,就能够合理地分配、调度、切换各个兄弟使用电脑的时间。又如:我们使用的操作系统,其进程和线程的调度就是一种抢先式任务模型。

合作式多任务模型,显然就是指执行任务的对象之间有团队精神,可以协同工作。第一个对象完成了一部分工作,就可以把成果交给第二个对象,让他在成果的基础上继续工作,并等待他完成。所以,我们使用纤程的主要目的,就是实现这种所谓的“协程”。比如:动物园的售票系统模型,观光者需要售票员处理售票相关的事项,然后把票递给自己;而售票员需要观光者把钱递给自己。这是在纤程的概念还没有出现时就存在于线程技术中的生产者与消费者关系的概念。

来看一个简单的例子:

fibonacci = Fiber.new do
  a = b = 1
  loop do
    b += a
    a = b - a
    Fiber.yield b
  end
end

10.times { p fibonacci.resume + 1000 }

输出:

1002
1003
1005
1008
1013
1021
1034
1055
1089
1144

我们这里是实现了打印出一个 Fibonacci 序列中前 10 个数 各加上 1000 后的结果的协程。fibonacci 是我们的生产者纤程,它的任务是生产出 Fibonacci 序列。在循环内部,Fiber.yield 会暂停执行,把执行权让给调用纤程的代码,并把参数作为 Fiber#resume 的返回值传递回去。在纤程外部,我们有一个十步的循环,作用是把 Fibonacci 序列前 10 个数各加上 1000 并打印出来。每次调用 Fiber#resume,都会恢复纤程的执行,并返回 Fiber.yield 的参数。这时如果我们给 resume 传递了参数,该参数就会被作为 Fiber.yield 的返回值传递给纤程(当然我们这里没有用到这个功能,但在模拟更复杂的合作式多任务时,是极有可能需要双向交互的)。

同样的效果,我们可以用一个循环就完成,即在生产 Fibonacci 数的同时打印(循环内部生成一个就打印一个),但这被认为是差劲的设计模式,因为它把生产和消费这两个过程混合到了一起。使用纤程的话,我们就能使它们泾渭分明。

同样的效果,我们也可以用线程来实现。但线程相比于纤程是重量级的,我们需要考虑对象锁之类的条件变量来进行线程同步,同时还得考虑潜在的线程安全问题。而我们的纤程,则拥有同步的天性,使得我们在使用时无须顾虑各种线程相关的复杂问题。

Ruby 1.9 内置的纤程只适用于小规模协程(二人合作),因为 Fiber.yield 只能把执行权返回给 Fiber#resume 的调用者。如果想实现规模更大的协程,我们就需要使用 Ruby 1.9 标准库中的 Fiber 了。

[RMXP] Hangup 异常根除

通用 FSL 信息

脚本说明

灼眼的夏娜曾经发布过一个解决 RMXP 10 秒不刷新就抛出 Hangup 异常,提示“脚本已备份”(这个错误信息是汉化失误)问题的脚本,其原理是创建一个 Ruby 线程,定期调用 Graphics.update,这样就能防止异常的抛出。这个解决方法有一个弊端,就是当 RM 进程阻塞时,Ruby 线程也会停止运行,超过 10 秒后仍如果进程从阻塞中恢复,仍然会抛出 Hangup 异常。我们自然而然地会想:难道不能从根本杜绝 Hangup 的抛出吗?

前不久在写精确获取窗口句柄的时候,发现 RMXP 游戏进程创建了不止一个的 Windows 线程,通过一个一个地结束线程发现,游戏进程最后创建的一个线程恰好就是控制 Hangup 异常抛出的线程。估计 10 秒的计时也是在这个线程内部的用户代码中进行的。那我们简单地、暴力地把这个线程咔嚓掉,不就搞定了吗?我这么做了,结果发现:原来处理程序最终化(即退出时)的也是这个线程,在我把这个线程咔嚓之后,无论是点窗口右上角的关闭按钮,所有脚本都解释完毕,还是在异常抛出到 Ruby 顶层后的正常退出都失效了,只能通过结束进程来关闭游戏。

想了几天,终于想到了解决方法:在程序初始化的时候就暂停掉这个线程,直到游戏需要退出的时候再恢复其用户代码的运行,这样,游戏过程中所有本来应该抛出 Hangup 异常的地方都被鞋盒掉了,这就是这个脚本的暴力之处。但关键就是——怎么在所有需要退出的场合下都进行线程的恢复?点窗口关闭按钮的场合很容易,可以重新定义 exit;所有脚本解释完毕的场合也很容易,可以在 main 脚本下面做相关的处理;而异常抛出到顶层的场合,似乎就不那么么简单了。你说可以在 main 脚本的 begin … rescue … end 那里捕获其它所有异常?那万一在前面定义 Game_Temp 啊 Scene_Title 啊之类的地方发生了异常(也就是解释到 main 脚本之前)怎么办呢?

现在的办法是这样的:游戏运行的时候,RM 会把脚本读取到一个全局数组 $RGSS_SCRIPTS 中,而实际在解释脚本的时候也是在访问这个数组的内容。我们可以越俎代庖,在 RM 解释所有脚本之前(不包括越俎代庖的这个脚本)先把脚本解释完了,然后直接退出程序。换句话说,就是通过 Ruby 的 eval 函数去代替了 RM 的 RGSSEval 函数,解释了所有的脚本。这样一来,在解释脚本过程中所有的异常(当然也不包括越俎代庖脚本中的异常,下面发布是经过调试后的脚本,应该不会有错误了,不过为了保险起见还望大家多多测试)都可以通过 rescue 来捕获到了!当然有利也有弊,这样做了之后,当异常抛出时,提示的消息就只有“eval:#{出错行数}#{出错信息}”,而没有为用户提供脚本的标题,并在脚本编辑器中把光标指向出错的地方了,这是这个脚本的副作用。

当然,发生错误的时候你完全可以暂时屏蔽掉这个脚本,这样就可以把异常后的效果恢复到从前那样了,反正调试的时候你也不一定需要这个脚本。另外,这个脚本在捕获到异常,恢复了线程的运行之后就直接把异常再次抛出给了顶层,所以异常错误信息没有脚本的标题。但其实在循环中,完全可以通过 $RGSS_SCRIPT[subscript] 的第二个元素获取到当前的脚本标题,有心人可以自己改一下,让脚本弹出消息框告诉你这些信息,而不是直接交给 RM 的异常处理机制来处理。

最后,这个脚本完全有可能引起未知的问题,因为被屏蔽掉的线程对我们普通 RM 用户来说还是未知的,万一它还处理了什么其它的东西呢?我们还需要更多的测试。

更新历史

  • 1.2.0827 By 紫苏

    • 更改了配置模块名
    • 更改了 FSL 注释信息
  • 1.2.0805 By 紫苏

    • 脚本开始遵循 FSL
    • 全局范围内改变了脚本结构
  • 1.1.1101 By 紫苏

    • 修正了脚本在 Windows XP 平台下失效的问题
  • 1.0.0927 By 紫苏

    • 初始版本完成

脚本源码

#==============================================================================
# ■  Hangup 异常根除
#    Hangup Exception Eradication
#----------------------------------------------------------------------------
#
#    Hangup 异常是 RMXP 底层引擎内置的一个异常类,游戏进程会在 Graphics.update
#    没有调用超过 10 秒时抛出这个异常。这个脚本使用了 Windows API 暴力地解除
#    了这个限制。
#    使用方法:Hangup 异常根除脚本必须插入到脚本编辑器的最顶端,所有脚本之前,无
#    例外。
#
#----------------------------------------------------------------------------
#
#    更新作者: 紫苏
#    许可协议: FSL -MEE
#    项目版本: 1.2.0827
#    引用网址: 
#    http://bbs.66rpg.com/forum.php?mod=viewthread&tid=134316
#    https://szsu.wordpress.com/2010/08/09/hangup_eradication
#
#----------------------------------------------------------------------------
#
#    - 1.2.0827 By 紫苏
#      * 更改了配置模块名
#      * 更改了 FSL 注释信息
#
#    - 1.2.0805 By 紫苏
#      * 脚本开始遵循 FSL
#      * 全局范围内改变了脚本结构
#
#    - 1.1.1101 By 紫苏
#      * 修正了脚本在 Windows XP 平台下失效的问题
#
#    - 1.0.0927 By 紫苏
#      * 初始版本完成
#
#==============================================================================

$__jmp_here.call if $__jmp_here

#----------------------------------------------------------------------------
# ● 登记 FSL。
#----------------------------------------------------------------------------
$fscript = {} if !$fscript
$fscript['HangupEradication'] = '1.2.0827'

#==============================================================================
# ■ FSL
#------------------------------------------------------------------------------
#  自由RGSS脚本通用公开协议的功能模块。
#==============================================================================

module FSL
  module HangupEradication
    #------------------------------------------------------------------------
    # ● 定义需要的 Windows API。
    #------------------------------------------------------------------------
    OpenThread = Win32API.new('kernel32', 'OpenThread', 'LIL', 'L')
    CloseHandle = Win32API.new('kernel32', 'CloseHandle', 'L', 'I')
    Thread32Next = Win32API.new('kernel32', 'Thread32Next', 'LP', 'I')
    ResumeThread = Win32API.new('kernel32', 'ResumeThread', 'L', 'L')
    SuspendThread = Win32API.new('kernel32', 'SuspendThread', 'L', 'L')
    Thread32First = Win32API.new('kernel32', 'Thread32First', 'LP', 'I')
    GetCurrentProcessId = Win32API.new('kernel32', 'GetCurrentProcessId', 'V', 'L')
    CreateToolhelp32Snapshot = Win32API.new('kernel32', 'CreateToolhelp32Snapshot', 'LL', 'L')
  end
end

#==============================================================================
# ■ HangupEradication
#------------------------------------------------------------------------------
#  处理根除 Hangup 异常的类。
#==============================================================================

class HangupEradication
  include FSL::HangupEradication
  #--------------------------------------------------------------------------
  # ● 初始化对像。
  #--------------------------------------------------------------------------
  def initialize
    @hSnapShot = CreateToolhelp32Snapshot.call(4, 0)
    @hLastThread = OpenThread.call(2, 0, self.getLastThreadId)
    #@hLastThread = OpenThread.call(2097151, 0, threadID)
    ObjectSpace.define_finalizer(self, self.method(:finalize))
  end
  #--------------------------------------------------------------------------
  # ● 获取当前进程创建的最后一个线程的标识。
  #--------------------------------------------------------------------------
  def getLastThreadId
    threadEntry = [28, 0, 0, 0, 0, 0, 0].pack("L*")
    threadId = 0                                          # 线程标识
    found = Thread32First.call(@hSnapShot, threadEntry)   # 准备枚举线程
    while found != 0
      arrThreadEntry = threadEntry.unpack("L*")           # 线程数据解包
      if arrThreadEntry[3] == GetCurrentProcessId.call    # 匹配进程标识
        threadId = arrThreadEntry[2]                      # 记录线程标识
      end
      found = Thread32Next.call(@hSnapShot, threadEntry)  # 下一个线程
    end
    return threadId
  end
  #--------------------------------------------------------------------------
  # ● 根除 Hangup 异常。
  #     2       : “暂停和恢复线程访问权限”代码;
  #     2097151 : “所有可能的访问权限”代码(Windows XP 平台下无效)。
  #--------------------------------------------------------------------------
  def eradicate
    SuspendThread.call(@hLastThread)
  end
  #--------------------------------------------------------------------------
  # ● 恢复 Hangup 异常。
  #--------------------------------------------------------------------------
  def resume
    while ResumeThread.call(@hLastThread) > 1; end        # 恢复最后一个线程
  end
  #--------------------------------------------------------------------------
  # ● 最终化对像。
  #--------------------------------------------------------------------------
  def finalize
    CloseHandle.call(@hSnapShot)
    CloseHandle.call(@hLastThread)
  end
end

hangupEradication = HangupEradication.new
hangupEradication.eradicate

callcc { |$__jmp_here| }                                  # F12 后的跳转标记

#==============================================================================
# ■ 游戏主过程
#------------------------------------------------------------------------------
#  游戏脚本的解释从这个外壳开始。
#==============================================================================

for subscript in 1...$RGSS_SCRIPTS.size
  begin
    eval(Zlib::Inflate.inflate($RGSS_SCRIPTS[subscript][2]))
  rescue Exception => ex
    # 异常发生并抛出给解释器时恢复线程。
    hangupEradication.resume unless defined?(Reset) and ex.class == Reset
    raise ex
  end
end

hangupEradication.resume
exit

已知 BUG 与冲突

  • 脚本在 Windows XP 平台下失效(已修复)