网安
  • Develop
    • JAVA学习
      • 字节码
      • API开发
      • Web开发
      • 工程结构推荐
      • 创建第一个项目
      • 权限管控
      • 配置文件
      • 日志管理
      • 数据访问
      • 性能监控
      • IoC容器
      • Spring
      • Filter与Listener
      • jsp
      • MVC
      • servlet-1
      • servlet-2
      • servlet-3
      • servlet-4
      • FreeMarker
      • Thymeleaf
      • EL
      • SpEL
      • JSTL
      • 部署
      • JDBC
      • 数据库连接池
      • fastjson
      • jackson
      • XML
      • JSON
      • 序列化
      • Maven
      • 安装与使用
      • 工具
      • 爬虫
    • GO学习
      • GO
        • flag 包
        • goland 远程调试
        • GoReleaser
        • OS 包
        • time 包
        • 格式化输出
    • Lua学习
      • Lua
      • 基础语法
      • LuaJIT
      • 与系统交互
    • Pyhon
      • 基础
      • Django
      • CLI
      • miniforge
      • MockingBird
      • pdb
      • pyc
      • 装的我脑血栓要犯了
      • Python101
      • 反序列化
      • 爬虫
      • Pillow
      • 图像识别
      • flask
    • Speed-Ruby
      • 入门1
      • 入门2 对象
      • 入门3 创建命令
      • Encoding类
      • File类与Dir类
      • IO
      • Proc类
      • Time类与Date类
      • 正则
      • 错误处理与异常
      • 对象、变量和常量
      • 方法
      • 数值
      • 数组
      • 条件判断
      • 循环
      • 运算符
      • Socket编程
      • 字符串
      • 并发与线程
      • 块
      • 类和模块
      • 散列
    • Web
      • HTTP
        • Connection
        • HTTP 报文
        • Vary
      • 笔记
        • 跳转
        • 认证 & 授权
        • 同源策略(SOP)
        • 文件
    • Git 学习笔记
    • JSON
      • JSON 学习笔记
    • HTML
      • Speed-HTML
      • 语法学习
      • HTML字符实体
    • XML
      • XML 学习笔记
    • 计算机基础
      • 操作系统
      • 计算机组成
      • 算法
      • 内存
      • 字符编码
    • gnuplot 学习笔记
    • regex
  • Integrated
    • Linux
      • God-Linux
      • Secure-Linux
      • Power-Linux
      • IO模型
      • Speed-Linux
      • 发行版
      • 工具
      • 启动过程
      • 进程
      • 认证
      • 日志
      • 守护进程
      • 文件
      • 信息
      • VSFTP 配置案例
      • auditd
      • containerd
      • DNS 配置案例
      • Docker
      • Docker-Compose
      • firewalld 实验
      • gpg
      • Iptables
      • httpd
      • LAMP
      • mysql
      • nfs 配置案例
      • openssl
      • PAM
      • samba 配置案例
      • terraform
      • ufw
      • VSFTP 配置案例
    • Network
      • Speed-Net
      • Power-Net
      • SDN 笔记
      • DNS
      • TLS
    • Windows
      • Secure-Win
      • Speed-Win
      • ACL
      • LDAP
      • IPC$(Internet Process Connection)
      • PDB符号文件
      • 工作组
      • WinRM
      • 角色权限
      • 凭据
      • 签名
      • 日志
      • 认证
      • 协议
      • 信息
      • 应用
      • 组策略
      • 域
      • asp站点搭建
      • Exchange 搭建
      • Windows 故障转移集群
      • Windows 基础服务搭建
      • Windows 域搭建
      • 本地抓包
      • PowerShell 笔记
    • 容器
      • Docker
    • 数据库
      • Speed-SQL
      • Power-SQL
      • MSSQL
      • MySQL
      • Postgresql
      • Redis
      • MySQL大小写问题
      • 主键和外键
      • MySQL快速入门
      • 虚拟化
        • ESXi
        • vCenter
  • Plan
    • Mac-Plan
    • Misc-Plan
    • Team-Plan
    • Thinking-Plan
    • VM-Plan
  • Sercurity
    • Power-PenTest
    • BlueTeam
      • 安全建设
      • 分析
      • 加固
      • 取证
      • 应急
      • USB取证
      • 磁盘取证
      • 内存取证
      • ClamAV 部署
      • yara 实验
      • 安防设施搭建使用
      • ZIP明文攻击
      • 流量分析
    • Crypto
      • Crypto
        • 2020 9 G60攻防大赛
        • CTF
        • 2020 9 中能融合杯工控CTF
        • 2020 10 全国工业互联网安全技术技能大赛江苏省选拔赛
        • 2020 10 全国网络与信息安全管理职业技能大赛江苏场
        • 2020 11 I²S峰会暨工业互联网安全大赛
        • 2021 6 第二届I²S峰会暨工业互联网安全大赛
        • 2021-9-第七届工控信息安全攻防竞赛
        • 2021 9 第七届全国职工职业技能大赛某市县选拔赛
        • 2021 9 全国网络与信息安全管理职业技能大赛江苏场
        • 2021-10-G60攻防大赛
    • CTF
      • CTF
      • writeup
        • 2020 9 中能融合杯工控CTF
        • 2020 9 G60攻防大赛
        • 2020 10 全国工业互联网安全技术技能大赛江苏省选拔赛
        • 2020 10 全国网络与信息安全管理职业技能大赛江苏场
        • 2020 11 I²S峰会暨工业互联网安全大赛
        • 2021 6 第二届I²S峰会暨工业互联网安全大赛
        • 2021-9-第七届工控信息安全攻防竞赛
        • 2021 9 第七届全国职工职业技能大赛某市县选拔赛
        • 2021 9 全国网络与信息安全管理职业技能大赛江苏场
        • 2021-10-G60攻防大赛
    • ICS
      • PLC攻击
      • S7comm 相关
      • 工控协议
      • 上位机安全
      • Modbus 仿真环境搭建
      • siemens 仿真搭建实验
      • S7-300 启停实验
    • IOT
      • 无线电安全
        • RFID复制卡
        • RFID基础知识
        • WiFikiller
      • 硬件安全
        • DIY键盘嵌入指纹识别模块实验记录
        • Device-Exploits
        • HID-Digispark
        • HID-KeyboardLogger
        • HID-USBHarpoon
        • HID-USBKeyLogger
      • 固件安全
        • 固件安全
        • Dlink_DWR-932B 路由器固件分析
    • Mobile sec
      • 小程序安全
      • Android安全
    • PWN
      • SLMail溢出案例
      • PWN
    • Red Team
      • OS安全
        • Linux 安全
        • Exploits
        • NTLM中继
        • Windows 安全
        • Responder欺骗
        • Windows-LOL
      • Web_Generic
        • Top 10
          • RCE
          • Fileread
          • SQLi
          • SSRF
          • SSTI
          • Web Generic
          • XSS
          • XXE
      • Web_Tricks
        • JWT 安全
        • HTTP_request_smuggling
        • OOB
        • 绕过访问
      • 靶场
        • Hello-Java-Sec 学习
        • DVWA-WalkThrough
        • pikachu-WalkThrough
        • upload-labs-WalkThrough
        • XVWA-WalkThrough
        • XSS挑战-WalkThrough
      • 实验
        • flask
        • fastjson
        • Log4j
        • nodejs
        • Shiro
        • Spring
        • Weblogic
      • 前端攻防
      • IDOR
    • 安防设备
      • Exploits
      • Bypass 技巧
    • 后渗透
      • 权限提升
      • 后渗透
      • 权限维持
      • 实验
        • C2 实验
        • Exchange
        • 端口转发实验
        • 代理实验
        • 免杀实验
        • 隧道实验
    • 软件服务安全
      • Exploits
      • CS Exploits
      • 实验
        • Docker
        • Kubernetes
        • Mysql
        • Oracle
        • PostgreSQL
        • Redis
        • vCenter
    • 协议安全
      • Exploits
    • 信息收集
      • 端口安全
      • 空间测绘
      • 信息收集
    • 语言安全
      • 语言安全
        • 语言安全
      • GO安全
        • GO安全
        • Go代码审计
      • JAVA安全
        • JAVA安全
        • JAVA代码审计
        • JAVA反序列化
        • SpEL 注入
      • PHP安全
        • PHP安全
        • bypass_disable_function
        • bypass_open_basedir
        • phpinfo
        • PHP代码审计
        • PHP反序列化
        • PHP回调函数
        • 变量覆盖
        • POP
        • 弱类型
        • 伪协议
        • 无字母数字Webshell
      • Python安全
        • pyc反编译
        • Python安全
        • Python 代码审计
        • 沙箱逃逸
      • dotnet安全
      • JS安全
    • 云安全
      • 公有云安全
    • Reverse
      • Reverse
      • FILE
        • ELF
        • BMP
        • JPG
        • PE
        • PNG
        • ZIP
        • 文件头
      • 实验
        • PYAble
          • 2-逆运算
          • 1-基本分析
          • 3-异或
          • 4-Base64
          • 5-Base64换表
          • 6-动态调试
        • Windows
          • condrv.sys 内存损坏漏洞
    • 工具
      • Aircrack
      • BloodHound
      • Burp Suite
      • frp
      • CobaltStrike
      • Ghidra
      • fscan
      • Hashcat
      • IDA
      • merlin
      • Kali
      • Metasploit
      • Mimikatz
      • ModSecurity
      • Nmap
      • nps
      • nuclei
      • pupy
      • RedGuard
      • SET
      • sliver
      • Snort
      • Sqlmap
      • Suricata
      • Sysmon
      • uncover
      • Volatility
      • Wfuzz
      • Wireshark
      • xray
    • 安全资源
      • 靶机
        • VulnHub
          • DC
            • DC2 WalkThrough
            • DC1 WalkThrough
            • DC3 WalkThrough
            • DC4 WalkThrough
            • DC5 WalkThrough
            • DC6 WalkThrough
            • DC9 WalkThrough
            • DC8 WalkThrough
          • It's_October
            • It’s_October1 WalkThrough
          • Kioptrix
            • Kioptrix2 WalkThrough
            • Kioptrix3 WalkThrough
            • Kioptrix4 WalkThrough
            • Kioptrix5 WalkThrough
          • Mission-Pumpkin
            • PumpkinGarden-WalkThrough
            • PumpkinFestival WalkThrough
            • PumpkinRaising WalkThrough
          • Symfonos
            • symfonos1 WalkThrough
            • symfonos2 WalkThrough
            • symfonos3 WalkThrough
            • symfonos5 WalkThrough
        • Wargames
          • Bandit
            • Bandit-WalkThrough
      • 面试问题
        • 面试问题
Powered by GitBook
On this page
  • 并发与线程
  • 简单例子
  • 线程生命周期
  • 线程互斥
  • Ruby 多线程和 IO Block
  • Ruby GIL 的影响
  • JRuby 去除了 GIL
  • GIL
  1. Develop
  2. Speed-Ruby

并发与线程

Previous字符串Next块

并发与线程


文章


简单例子

语法

要启动一个新的线程,只需要调用 Thread.new 即可:

# 线程 #1 代码部分
Thread.new {
  # 线程 #2 执行代码
}
# 线程 #1 执行代码
#!/usr/bin/ruby

def func1
   i=0
   while i<=2
      puts "func1 at: #{Time.now}"
      sleep(2)
      i=i+1
   end
end

def func2
   j=0
   while j<=2
      puts "func2 at: #{Time.now}"
      sleep(1)
      j=j+1
   end
end

puts "Started At #{Time.now}"
t1=Thread.new{func1()}
t2=Thread.new{func2()}
t1.join
t2.join
puts "End at #{Time.now}"

以上代码执行结果为:

Started At Wed May 14 08:21:54 -0700 2014
func1 at: Wed May 14 08:21:54 -0700 2014
func2 at: Wed May 14 08:21:54 -0700 2014
func2 at: Wed May 14 08:21:55 -0700 2014
func1 at: Wed May 14 08:21:56 -0700 2014
func2 at: Wed May 14 08:21:56 -0700 2014
func1 at: Wed May 14 08:21:58 -0700 2014
End at Wed May 14 08:22:00 -0700 2014

基本情况

并发的一个应用场景是计算密集的任务。举个例子:比如我们要分别分析100个用户的资料,分析过程可能非常复杂,每次分析需要超过100ms(简单起见,我用 fib 方法模拟),代码如下:

require "benchmark"

def fib(n)
  n < 2 ? n : fib(n - 1) + fib(n - 2)
end

def heavy_task
  fib(30)
end

puts Benchmark.measure{
  100.times do |i|
    heavy_task
  end
}

运行一下,耗时:

12.650000   0.050000  12.700000 ( 12.830092)

用时12秒!在此场景中,显然每一个用户的资料分析是独立的任务,也就是说,不需要等A用户的分析完成了才分析B用户,每个任务都可以同时进行。那么,用并发优化它。

Fork

在实现层面,并发可以通过 2 种方式进行:

  1. 通过 fork 子进程

  2. 通过新线程

下文会详细比较它们两者,我们首先尝试用多进程实现的并发优化这段代码。

Ruby 提供 fork 接口,它实际上是通过 调用 POSIX 标准的系统调用产生子进程的 。新产生的子进程与父进程不共享内存,它有新的堆栈;子进程由系统调度,所以,它与父进程是并发执行的。

通过 Fork 优化代码,并发耗时严重的任务:

require "benchmark"

def fib(n)
  n < 2 ? n : fib(n - 1) + fib(n - 2)
end

def heavy_task
  fib(30)
end

puts Benchmark.measure{
  100.times do |i|
    fork do
      heavy_task
    end
  end
  Process.waitall
}

优化后的结果:

  0.000000   0.030000  18.660000 (  2.379606)

2.3秒!使用 fork,这段代码快了将近5倍。

但是等等,通过 fork 实现的并发会引入新的问题:每 fork 都会把父进程的堆栈空间 完整地复制一次到子进程内存中,也就是说,如果你的应用需要 20mb 的内存,这段简单的 fork 会消费 20mb * 100 = 2GB 的内存空间!而 fork 的目的不过是并发执行一个耗时的方法。

除了需要更多的内存空间之外,多进程的另一个缺点是:子进程之间必须以 IPC 通信(如管道),设想你需要把这些处理结果都放在一个数组里返回,用多进程优化并发处理后,数据就难以汇集了。

显然,在这个场景里,多线程并发是更好的选择。

Thread

那么,试试用多线程处理这段代码吧:

require "benchmark"

def fib(n)
  n < 2 ? n : fib(n - 1) + fib(n - 2)
end

def heavy_task
  fib(30)
end

threads = []

puts Benchmark.measure{
  100.times do |i|
    threads << Thread.new do
      heavy_task
    end
  end
  threads.map(&:join)
}

执行结果:

11.660000   0.080000  11.740000 ( 11.806760)

怎么回事?使用多线程只比串行版本快不到 10%。

答案是 Global Interpreter Lock(GIL)。因为有 GIL 的存在,Ruby 的 VM 并不真正支持多线程。

在此例中,heavy_task 是一个 CPU 密集型的任务,所以在 MRI 中,多线程几乎不能带来任何优化。

但对于一些 IO 密集型的场景,如 http 请求,GIL 对线程的影响就没那么大,多线程并发仍然可以提供不错的优化的。

当然,也有不使用 GIL 的 Ruby解释器,如 JRuby


线程生命周期

  1. 线程的创建可以使用 Thread.new,同样可以以同样的语法使用 Thread.start 或者 Thread.fork 这三个方法来创建线程。

  2. 创建线程后无需启动,线程会自动执行。

  3. Thread 类定义了一些方法来操控线程。线程执行 Thread.new 中的代码块。

  4. 线程代码块中最后一个语句是线程的值,可以通过线程的方法来调用,如果线程执行完毕,则返回线程值,否则不返回值直到线程执行完毕。

  5. Thread.current 方法返回表示当前线程的对象。 Thread.main 方法返回主线程。

  6. 通过 Thread.Join 方法来执行线程,这个方法会挂起主线程,直到当前线程执行完毕。

线程状态

线程状态 | 返回值

  • | - 可执行 | run 睡眠 | Sleeping 退出 | aborting 正常终止 | false 发生异常终止 | nil

线程和异常

当某线程发生异常,且没有被 rescue 捕捉到时,该线程通常会被无警告地终止。但是,若有其它线程因为 Thread#join 的关系一直等待该线程的话,则等待的线程同样会被引发相同的异常。

begin
  t = Thread.new do
    Thread.pass    # 主线程确实在等join
    raise "unhandled exception"
  end
  t.join
rescue
  p $!  # => "unhandled exception"
end

使用下列3个方法,就可以让解释器在某个线程因异常而终止时中断运行。

  • 启动脚本时指定 -d 选项,并以调试模时运行。

  • 用 Thread.abort_on_exception 设置标志。

  • 使用 Thread#abort_on_exception 对指定的线程设定标志。

当使用上述3种方法之一后,整个解释器就会被中断。

t = Thread.new { ... }
t.abort_on_exception = true

线程同步控制

在 Ruby 中,提供三种实现同步的方式,分别是:

  1. 通过 Mutex 类实现线程同步

  2. 监管数据交接的 Queue 类实现线程同步

  3. 使用 ConditionVariable 实现同步控制

  • 通过 Mutex 类实现线程同步

    通过 Mutex 类实现线程同步控制,如果在多个线程钟同时需要一个程序变量,可以将这个变量部分使用 lock 锁定。 代码如下:

    require "thread"
    puts "Synchronize Thread"
    
    @num=200
    @mutex=Mutex.new
    
    def buyTicket(num)
        @mutex.lock
              if @num>=num
                  @num=@num-num
                  puts "you have successfully bought #{num} tickets"
              else
                  puts "sorry,no enough tickets"
              end
        @mutex.unlock
    end
    
    ticket1=Thread.new 10 do
        10.times do |value|
        ticketNum=15
        buyTicket(ticketNum)
        sleep 0.01
        end
    end
    
    ticket2=Thread.new 10 do
        10.times do |value|
        ticketNum=20
        buyTicket(ticketNum)
        sleep 0.01
        end
    end
    
    sleep 1
    ticket1.join
    ticket2.join

    以上代码执行结果为:

    Synchronize Thread
    you have successfully bought 15 tickets
    you have successfully bought 20 tickets
    you have successfully bought 15 tickets
    you have successfully bought 20 tickets
    you have successfully bought 15 tickets
    you have successfully bought 20 tickets
    you have successfully bought 15 tickets
    you have successfully bought 20 tickets
    you have successfully bought 15 tickets
    you have successfully bought 20 tickets
    you have successfully bought 15 tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets
    sorry,no enough tickets

    除了使用 lock 锁定变量,还可以使用 try_lock 锁定变量,还可以使用 Mutex.synchronize 同步对某一个变量的访问。

  • 监管数据交接的 Queue 类实现线程同步

    Queue 类就是表示一个支持线程的队列,能够同步对队列末尾进行访问。不同的线程可以使用统一个队列,但是不用担心这个队列中的数据是否能够同步,另外使用 SizedQueue 类能够限制队列的长度

    SizedQueue 类能够非常便捷的帮助我们开发线程同步的应用程序,因为只要加入到这个队列中,就不用关心线程的同步问题。

    经典的生产者消费者问题:

    require "thread"
    puts "SizedQuee Test"
    
    queue = Queue.new
    
    producer = Thread.new do
        10.times do |i|
              sleep rand(i) # 让线程睡眠一段时间
              queue << i
              puts "#{i} produced"
        end
    end
    
    consumer = Thread.new do
        10.times do |i|
              value = queue.pop
              sleep rand(i/2)
              puts "consumed #{value}"
        end
    end
    
    consumer.join

线程变量

线程可以有其私有变量,线程的私有变量在线程创建的时候写入线程。可以被线程范围内使用,但是不能被线程外部进行共享。

但是有时候,线程的局部变量需要别别的线程或者主线程访问怎么办?ruby 当中提供了允许通过名字来创建线程变量,类似的把线程看做 hash 式的散列表。通过 []= 写入并通过 [] 读出数据。我们来看一下下面的代码:

count = 0
arr = []

10.times do |i|
   arr[i] = Thread.new {
      sleep(rand(0)/10.0)
      Thread.current["mycount"] = count
      count += 1
   }
end

arr.each {|t| t.join; print t["mycount"], ", " }
puts "count = #{count}"

以上代码运行输出结果为:

8, 0, 3, 7, 2, 1, 6, 5, 4, 9, count = 10

主线程等待子线程执行完成,然后分别输出每个值。

线程优先级

线程的优先级是影响线程的调度的主要因素。其他因素包括占用 CPU 的执行时间长短,线程分组调度等等。

可以使用 Thread.priority 方法得到线程的优先级和使用 Thread.priority= 方法来调整线程的优先级。

线程的优先级默认为 0 。优先级较高的执行的要快。

一个 Thread 可以访问自己作用域内的所有数据,但如果有需要在某个线程内访问其他线程的数据应该怎么做呢? Thread 类提供了线程数据互相访问的方法,你可以简单的把一个线程作为一个 Hash 表,可以在任何线程内使用 []= 写入数据,使用 [] 读出数据。

athr = Thread.new { Thread.current["name"] = "Thread A"; Thread.stop }
bthr = Thread.new { Thread.current["name"] = "Thread B"; Thread.stop }
cthr = Thread.new { Thread.current["name"] = "Thread C"; Thread.stop }
Thread.list.each {|x| puts "#{x.inspect}: #{x["name"]}" }

可以看到,把线程作为一个 Hash 表,使用 [] 和 []= 方法,我们实现了线程之间的数据共享。


线程互斥

Mutex(Mutal Exclusion = 互斥锁)是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。

不使用 Mutax 的实例

require 'thread'

count1 = count2 = 0
difference = 0
counter = Thread.new do
   loop do
      count1 += 1
      count2 += 1
   end
end
spy = Thread.new do
   loop do
      difference += (count1 - count2).abs
   end
end
sleep 1
puts "count1 :  #{count1}"
puts "count2 :  #{count2}"
puts "difference : #{difference}"

以上实例运行输出结果为:

count1 :  9712487
count2 :  12501239
difference : 0

使用 Mutax 的实例

require 'thread'
mutex = Mutex.new

count1 = count2 = 0
difference = 0
counter = Thread.new do
   loop do
      mutex.synchronize do
         count1 += 1
         count2 += 1
      end
    end
end
spy = Thread.new do
   loop do
       mutex.synchronize do
          difference += (count1 - count2).abs
       end
   end
end
sleep 1
mutex.lock
puts "count1 :  #{count1}"
puts "count2 :  #{count2}"
puts "difference : #{difference}"

以上实例运行输出结果为:

count1 :  1336406
count2 :  1336406
difference : 0

死锁

两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,这种状况,就称为死锁。

例如,一个进程 p1占用了显示器,同时又必须使用打印机,而打印机被进程 p2 占用,p2 又必须使用显示器,这样就形成了死锁。

当我们在使用 Mutex 对象时需要注意线程死锁。

require 'thread'
mutex = Mutex.new

cv = ConditionVariable.new
a = Thread.new {
   mutex.synchronize {
      puts "A: I have critical section, but will wait for cv"
      cv.wait(mutex)
      puts "A: I have critical section again! I rule!"
   }
}

puts "(Later, back at the ranch...)"

b = Thread.new {
   mutex.synchronize {
      puts "B: Now I am critical, but am done with cv"
      cv.signal
      puts "B: I am still critical, finishing up"
   }
}
a.join
b.join

以上实例输出结果为:

A: I have critical section, but will wait for cv
(Later, back at the ranch...)
B: Now I am critical, but am done with cv
B: I am still critical, finishing up
A: I have critical section again! I rule!

Web 应用大多是 IO 密集型的,利用 Ruby 多进程 + 多线程模型将能大幅提升系统吞吐量。其原因在于:当 Ruby 某个线程处于 IO Block 状态时,其它的线程还可以继续执行。但由于存在 Ruby GIL (Global Interpreter Lock),MRI Ruby 并不能真正利用多线程进行并行计算。JRuby 去除了 GIL,是真正意义的多线程,既能应付 IO Block,也能充分利用多核 CPU 加快整体运算速度。

Ruby 多线程和 IO Block

先看下面一段代码

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

func1
func2
func3

代码很简单,3 个方法,用 sleep 模拟耗时的 IO 操作。 运行代码(环境 MRI Ruby 1.9.3) 结果是:

$ time ruby block_io1.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m11.681s
user  0m3.086s
sys 0m0.152s

比较慢,时间都耗在 sleep 上了,总共花了 10 多秒。

采用多线程的方式,改写如下:

def func1
  puts "sleep 3 seconds in func1\n"
  sleep(3)
end

def func2
  puts "sleep 2 seconds in func2\n"
  sleep(2)
end

def func3
  puts "sleep 5 seconds in func3\n"
  sleep(5)
end

threads = []
threads << Thread.new { func1 }
threads << Thread.new { func2 }
threads << Thread.new { func3 }

threads.each { |t| t.join }

运行的结果是:

$ time ruby block_io2.rb
sleep 3 seconds in func1
sleep 2 seconds in func2
sleep 5 seconds in func3

real  0m6.543s
user  0m3.169s
sys 0m0.147s

总共花了 6 秒多,明显快了许多,只比最长的 sleep 5 秒多了一点。

上面的例子说明,Ruby 的多线程能够应付 IO Block,当某个线程处于 IO Block 状态时,其它的线程还可以继续执行,从而使整体处理时间大幅缩短。

  • 另一种写的方式

    def func1
    puts "sleep 3 seconds in func1\n"
    sleep(3)
    end
    
    threads = []
    10.times do
    threads << Thread.new { func1 }
    end
    
    threads.each { |t| t.join }

Ruby GIL 的影响

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

16.times { Zlib::Deflate.deflate(data) }

代码先随机生成一些数据,然后对其进行压缩,压缩是非常耗 CPU 的,在我机器 (双核 CPU, MRI Ruby 1.9.3) 运行结果如下:

$ time ruby gil1.rb

real  0m8.572s
user  0m8.359s
sys 0m0.102s

更改为多线程版本,代码如下:

require 'securerandom'
require 'zlib'

data = SecureRandom.hex(4096000)

threads = []
16.times do
  threads << Thread.new { Zlib::Deflate.deflate(data) }
end

threads.each {|t| t.join}

多线程的版本运行结果如下:

$ time ruby gil2.rb

real  0m8.616s
user  0m8.377s
sys 0m0.211s

从结果可以看出,由于 MRI Ruby GIL 的存在,Ruby 多线程并不能重复利用多核 CPU,使用多线程后整体所花时间并不缩短,反而由于线程切换的影响,所花时间还略有增加。

JRuby 去除了 GIL

使用 JRuby (我的机器上是 JRuby 1.7.0) 运行 gil1.rb 和 gil2.rb,得到很不一样的结果。

$ time jruby gil1.rb

real  0m12.225s
user  0m14.060s
sys 0m0.615s
$ time jruby gil2.rb

real  0m7.584s
user  0m22.822s
sys 0m0.819s

可以看到,JRuby 使用多线程时,整体运行时间有明显缩短(7.58 比 12.22),这是由于 JRuby 去除了 GIL,可以真正并行的执行多线程,充分利用了多核 CPU。

补充说明,Ruby 2.0 Zlib 库去除了 GIL

在 Ruby 2.0 下,由于 Zlib 去除了 GIL,见:https://github.com/ruby/ruby/blob/v2_0_0_0/NEWS#L512-L513/,执行多线程版本 gil2.rb 有非常大的性能提升。 详细数据见:

time ruby gil1.rb

real    0m8.708s
user    0m8.664s
sys 0m0.025s

time ruby gil2.rb

real    0m2.102s
user    0m17.630s
sys 0m0.147s

这是在一台 单 CPU 6 核(带超线程)机器,在 ruby-2.0.0-p195 的执行结果。 但是对于普通的 Ruby 代码 和 类库,Ruby 2.0 还是有 GIL 存在,限制利用多线程并行能力。 我另外构建了例子 gil_digest1.rb 和 gil_digest2.rb (见 Github 项目),在 Ruby 2.0 下运行并没有性能提升。 感谢 @zj0713001 @hooopo @5long 的提醒和说明。

总结:Ruby 多线程可以在某个线程 IO Block 时,依然能够执行其它线程,从而降低 IO Block 对整体的影响,但由于 MRI Ruby GIL 的存在,MRI Ruby 并不是真正的并行执行,JRuby 去除了 GIL,可以做到真正的多线程并行执行。


GIL

MRI 里有个东西叫全局解释器锁(global interpreter lock)。这个锁环绕着 Ruby 代码的执行。即是说在一个多线程的上下文中,在任何时候只有一个线程可以执行 Ruby 代码。 因此,假如一台8核机器上跑着8个线程,在特定的时间点上也只有一个线程和一个核心在忙碌。GIL 一直保护着 Ruby 内核,以免竞争条件造成数据混乱。把警告和优化放一边,这就是它的主旨了。

数组附加是非线程安全的

几乎没什么事在 Ruby 里是隐式线程安全的。以附加数组为例:

array = []

5.times.map do
  Thread.new do
    1000.times do
      array << nil
    end
  end
end.each(&:join)

puts array.size

这里有 5 个线程共享一个数组对象。每个线程将 nil 放入数组 1000 次。因此,数组里应该有 5000 个元素,对吧?

$ ruby pushing_nil.rb
5000

$ jruby pushing_nil.rb
4446

$ rbx pushing_nil.rb
3088

即使这个微不足道的例子,也足以揭示 Ruby 里的一个操作并非隐式线程安全的。或许是?实际上发生什么了呢?

请注意 MRI 的结果是正确的, 5000。但是 JRuby 和 Rubinius 都错了。如果你再跑一遍,你很可能会看到 MRI 依然正确,但是 JRuby 和 Rubinius 给出了不同的错误结果。

这些不同的结果是 GIL 造成的。因为 MRI 有 GIL,即使同时有 5 个线程在跑,在一个时间点上也只有一个线程是活动的。JRuby 和 Rubinius 没有 GIL,所以当你有 5 个线程在跑,你就真的有 5 个线程通过获取核心在并行地跑。

多线程如何腐化数据

无论你是用 MRI,JRuby 或是 Rubinius,Ruby 语言是用其他语言实现的。 MRI 是用 C 实现的,JRuby 用 Java,Rubinius 是 Ruby 和 C++ 的混合体。于是当你有这样一个 Ruby 操作时:

array <<< nil

实际上在底层实现上会扩展为一大堆代码。例如,下面是 Array#<<在 MRI 中的实现:

VALUE

rb_ary_push(VALUE ary, VALUE iterm)
{
   long idx = RARRAY_LEN(ary);

   ary_ensure_room_for_push(ary, 1);
   RARRAY_ASET(ary, idx, item);
   ARY_SET_LEN(ary, idx + 1);
   return ary;
}

注意至少 4 个不同的底层操作。

  • 获取数组的当前长度

  • 检查数组里是否有空间容纳其他元素。

  • 将元素附件到数组

  • 将数组的长度属性置为原值 +1。

每个操作还回调用别的函数或者宏。我提到这些是为了向你们展示多线程是如何能够破坏数据的。在但线程环境中,你可以观察并简单地跟踪这个短代码的轨迹。

话句话说,我们已经习惯了以线性的方式逐句执行代码并推断"真实世界"的状态。我们通常就是这么写代码的。

当多线程乱入,这就不可行了。这很像物理变化的规则。当有两个线程,每个线程维护这个自己的代码轨迹。由于线程共享同一个内存空间,而这些线程可以同时改变"真实世界"中的状态。

一个线程可能会打扰另一个线程,从此改变事物的状态,之后原先的线程完全不知状态已经被改变了。

这里是我的小系统的基本状态:

有两个活跃线程,同时进入这个函数 (C 语言中的)。将 1-4 步看做 MRI 中 Array#<< 的伪代码实现,之前你见过的。一旦两个线程进入这个函数,就可能出现一系列事件,假设从线程 A 开始:

这看着更复杂了,但是只要跟着箭头的方向,你就可以穿过这个流程。我还加了在每个步骤上一些标签从每个线程的角度来显示各种状态。

这只是其中一种可能性。

于是线程 A 沿着函数的常规路径执行,但当执行到步骤 3 时,发生了上下文切换!线程 A 被暂停在当前位置。之后线程 B 接管了进程并运行整个函数,附加它自己的元素并增加length属性。

一旦线程 B 完事了,线程 A 就恢复执行。A 会在其中断的位置走起。记住,线程 A 是在增加length属性前被暂停的,自然会从往下增加length属性。只不过,A 并不知道线程 B 已经改变了事物的状态。

于是线程 B 设置length为 1,之后线程 A 又把length设为 1,尽管它们格子的元素都已经被附加到了 Array 上。数据已经被玩坏了。看到图中的小闪电了吗,就这这个意思。

如图中例子所示,JRuby 和 Rubinius 中的这一系列的事件会带来错误的结果。

除此之外,在 JRuby 和 Rubinius 里,事情要更为复杂,因为线程实际可以平行跑。在该图中,一个线程被暂停,另一个在运行,而在真正并行的环境里,多个线程可以同时运行。

所以,为什么 Ruby 不保护我们远离这些? 出于同样的原因,其他一些编程语言内核也不提供线程安全保护:它成本太高。对所有的 Ruby 实现提供线程安全的数据结构不是不可能,但这需要额外的开销,拖了单线程代码的后腿。

上下文切换源于操作系统的线程调度程序。在所有我展示过的 Ruby 语言实现中,一个 Ruby 线程依托于一个原生的操作系统线程。操作系统必须保证没有一个线程可以独霸所有可用资源,如 CPU 时间,于是它实现了调度算法,使得雨露均沾。

这表现为一系列的暂停会恢复。每个线程都有机会消耗资源,之后它暂停在其轨道上,以便其他线程可以有机可乘。随着时间推移,这个线程经会被不断被恢复。

这一做法提高了操作系统的效率,但也引入和一定程度的不确定性和程序正确性的难度。例如,Array#<<操作现在需要考虑到它可以随时暂停,另一个线程可以并行地执行相同的操作,改变脚下"世界"的状态。

如果想确保这样的线程间中断不发生,就应该使操作具有原子性。通过原子性操作,可以保证线程在完成动作前不会被打断,这就防止了我们例子中的,在步骤 3 被打断,并最终在步骤 4 时恢复导致的数据误。

是操作具有原子性的最简方案是使用锁。下面的代码会确保结果的正确,不论是在 MRI,JRuby 还是 Rubinius 里。

array = []
mutex = Mutex.new

5.times.map do
  Thread.new do

    mutex.synchronize do
      1000.times do
        array << nil
      end
    end

   end
end.each(&:join)

puts array.size

它确保正确是因为使用了一个共享的互斥或者说锁。一旦一个线程进入 mutex.synchronize 内的代码块时,所有其他线程必须在进入同一代码前等待,直到这个线程执行完毕。如果你回想前面,我说过这个操作下是多行 C 代码,并且线程调度上下文切换可以发生在任意两行代码间。

通过原子性操作,你可以保证如果一个上下文切换在这个代码块里发生了,其他线程将无法执行相同的代码。线程调度器会观察这一点,并再切换另一个线程。这同样也保证了没有线程可以一同进入代码块并各自改变"世界"的状态。这个例子现在就是线程安全的。

GIL 也是个锁

我刚才已经展示乐怎样可以使用锁得到原子性并提供好线程安全保证。GIL 也是一个锁,所以它也能保证你代码的线程安全吗?

GIL 会使 array << nil 变成原子性操作吗?

下面的片段是我们上次见过了:

array = []
mutex = Mutex.new

5.times.map do
  Thread.new do

    mutex.synchronize do
      1000.times do
        array << nil
      end
    end

   end
end.each(&:join)

puts array.size

如果你假设 Array 是线程安全的,预计的结果是数组会有 5000 个元素。因为数组不是线程安全的,在 JRUby 和 Rubinius 的实现中产生了不期的结果;比 5000 少。这是多线程间交互切换造成的底层数据错误。

MRI 产生了预期结果,这是侥幸还是必然呢? 让我们用这个 Ruby 代码片段来进行技术深潜。

Thread.new do
  array << nil
end

为了学习这个片段中到底发生了什么,我们需要 MRI 内部是如何衍生线程的。我们主要看 thread*.c 文件中的那些函数。这些文件中有不少迂回之处,来同时支持 Windows 和 Posix 的线程 APIs,但是这个些函数都是从这些源码文件中看来的。

第一个 Thread.now 底层操作是衍生一个新的原生线程来支持 Ruby 线程。成为新线程主体的 C 函数称为 thread_start_func_2。在接近顶部的地方,新线程获取 GIL。注意,这个线程会保持空闲,直到它确实获得了 GIL。在函数中部,它调用你传给 Thread.new的那个代码块。包装事物后,它释放 GIL 并退出原生线程。

在我们的片段中,这个新线程衍生于主线程。有鉴于此,我们可以假设主线程当前正持有 GIL。新线程将必须等待,直到主线程释放 GIL,它才能继续。

让我们看一下当新线程尝试获取 GIL 时发生了什么吧。

static void
gvl_acquire_common(rb_vm_t *vm)
{
  if (vm->gvl.acquired) {
    vm->gvl.waiting++;
    if (vm->gvl.waiting == 1) {
      rb_thread_wakeup_timer_thread_low();
    }

    while (vm->gvl.acquired) {
      native_cond_wait(&vm->gvl.cond, &vm->gvl.lock);
    }

此函数在我们的新线程尝试获取 GIL 时被调用。

首先,它会检查 GIL 当前是否被占有了,之后它增加 GIL 的 waiting 属性。同我们的片段,这个值应该现在为 1。紧接着的一行检查看 wating 是否是 1。它正是 1,于是下一行触发唤醒了个计时器线程。

计时器线程是 MRI 中线程系统能一路高歌的秘密武器,并避免任意线程独霸 GIL。但在我们跳得太远之前,先让我们阐述一个 GIL 相关事物的状态,然后再来介绍计时器线程。

我前面说了几次,MRI 线程依靠的是原生的操作系统线程。这是真的,但是如图中所示,每个 MRI 线程并行运行在各自的原生线程中。GIL 阻止这样。我们需要画出 GIL 来让其更为接近事实。

当一个 Ruby 线程希望在它自己的原生线程中执行代码时,必须先获得 GIL。GIL 在 Ruby 线程和它们各自的原生进程之间周旋,极力消减并发! 上张图里,Ruby 线程在其原生线程里可以并行执行。而第二张更接近 MRI 事实真相的图里,在特定时间点上只有一个线程可以获取 GIL,于是代码的执行是完全不能并行的。

对 MRI 核心组而言,GIL 保卫着系统的内部状态。使用 GIL,他们不需要在数据结构周围使用任何锁或者同步机制。如果两个线程不能够同时改变内部状态,也就不会有竞争条件发生了。

对你,开发者而言,这会大大限制你从 MRI Ruby 代码中获得的并发能力。

计时器线程

我前面提到计时器线程是用来避免一个线程独霸 GIL 的。计时器线程只是一个存在于 MRI 内部的原生线程;它没有相应的 Ruby 线程。计时器线程在 MRI 启动时以rb_thread_create_timer_thread函数启动。

当 MRI 启动并只有主线程运行时,计时器线程沉睡。但请记住,一旦有一个线程在等待 GIL,它即会唤醒计时器线程。

这张图更近乎于 MRI 中 GIL 的实现。回想之前的片段,我刚刚衍生出最右边的线程。因为它是唯一在等待 GIL 的,就是它唤醒计时器线程的。

计时器线程是用来避免一个线程独霸 GIL 的。每 100 毫秒,计时器线程在当前持有 GIL 的线程上设置一个中断标志,使用 RUBY_VM_SET_TIMER_INTERRUPT 宏。这里的细节需要注意,因为这会给array << nil 是否是原子性操作这个问题提供线索。

如果你熟悉时间片的概念,与此很相似。

每 100 毫秒计时器线程会在当前持有 GIL 的线程上设置中断标记。设置中断标记并不实际中断线程的执行。如果是这样的话,我们可以肯定array << nil不是一个原子性操作。

控制中断标志

深入名为vm_eval.c的文件,包含了控制 Ruby 方法调用的代码。它有负责创建方法调用的上下文,并调用正确的方法。在方法结束时调用vm_call0_body,正当它返回当前方法的返回值之前,这些中断会被检查。

如果这个线程已经被设置了中断标志,则在返回其值前当场停止执行。在执行更多 Ruby 代码之前,当前线程会释放 GIL 并调用sched_yield。sched_yield是一个系统方法提示线程调度器安排另一个线程。一旦完成这个工作,该中断线程尝试重新获取 GIL,现在不得不等待另一个线程释放 GIL 了。

嘿,这就是我们问题的答案。array << nil是原子性的。多亏了 GIL,所有用 C 实现的 Ruby 方法都是原子性的。

所以这个例子:

array = []

5.times.map do
  Thread.new do
    1000.times do
      array << nil
    end
  end
end.each(&:join)

puts array.size

运行在 MRI 上每次都保证产生预期的结果。

但请记住,这个保证并不针对 Ruby 写成的那些代码。如果你把这段代码放到没有 GIL 的其他实现里,它将会产生叵测的结果。很有必要了解一个 GIL 保证,但依赖它来写代码就不是个好主意了。在此过程中,你基本就把自己和 MRI 捆绑在一块儿了。

相似的,GIL 不是公开的 API。没有文档和规程说明。虽说 Ruby 代码是隐式依赖 GIL 的,但之前的 MRI 团队曾谈及想摆脱 GIL 或改变其语义。出于这些原因,你当然不希望,写出来的代码只能依赖于现下 GIL 的行为吧。

非原生方法

目前为止,我说到 array << nil 是原子性的。这很简单,因为 Array#<< 方法只带一个参数。这个表达式里只有一个方法调用,并且它是用 C 实现的。如果它在过程中被中断了,只会继续直到完成,之后释放 GIL。

那类似这样的呢?

array << User.find(1)

在 Array#<< 方法执行前,它先要对右侧的表达式进行求值,然后才能把表达式的值作为参数。所以 User.find(1) 必须先被调用。如你所知,User.find(1) 会调用一大堆其他 Ruby 代码。

所以,在上面的例子中 Array#<< 依然是原子性的吗?是的,但是一旦右手边被求值。换句话说,没有原子性保证 User.find(1) 方法将被调用。之后返回值会传给 有原子性保证的 Array#<<。

这一切意味着什么?

GIL 使得方法调用原子性。这个对你意味着什么呢?

在 Part I 中,我举例展示了在 C 函数中发生上下文切换时会发生什么。使用 GIL,这种情况不会再发生了。相反,如果上下文切换发生了,其他线程会保持空闲以待 GIL,给当前线程机会继续不中断。此行为只适用于 MRI 用 C 实现的 Ruby 方法。

这种行为消除了竞争条件的源头,不然 MRI 的内部竞争会防不胜防。从这个角度,GIL 是一个严格的 MRI 内部实现细节。它保持 MRI 的安全。

但是还有一个挥之不去的问题尚无答案。GIL 能提供给你的 Ruby 代码线程安全保证吗?

这是一个 MRI 使用中的重要问题,要是你熟悉其他环境的多线程编程,你可能已经知道了,GIL 不会使你的 Ruby 代码线程安全。

但请别这么相信我。

这个系列一开始只是为了从技术层面上了解 GIL。Part I 解释了竞争条件是如何在实现 MRI 的 C 源码中发生的。还有,GIL 貌似排除了风险,至少我们看到 Array#<< 方法是这样。

Part II 证实了 GIL 的作为,实际上,它使得 MRI 的原生 C 方法实现原子化了。换而言之,这些原生方法是对竞争条件免疫的。这个保证只针对 MRI 的 C 原生方法,你自己写的那些 Ruby 可不行。 于是我得到一个遗留问题:

GIL 能否保证我们的 Ruby 代码是线程安全的?

我已经回答过这个问题了。现在我想确保谣言止于智者。

归来的竞争条件

竞争条件发生在一些数据块在多个线程之间共享,并且这些线程企图同时在数据上进行操作的时候。当发生时没有一种同步机制,比如锁,你的程序会开始做一些意料之外的事,并且数据也会遗失。

让我们回过头来回顾一下这种竞争状态是如何发生的。我们将使用如下 Ruby 代码作为本节的示例:

class Sheep
  def initialize
    @shorn = false
  end

  def shorn?
    @shorn
  end

  def shear!
    puts "shearing..."
    @shorn = true
  end
end

这个类定义应该很常见。一头羊在初始化的时候是没被薅过的。shear!方法执行薅羊毛并标记这头羊为薅过。

sheep = Sheep.new

5.times.map do
  Thread.new do
    unless sheep.shorn?
      sheep.shear!
    end
  end
end.each(&:join)

这一小段代码创建了头羊并且衍生出 5 个线程。每个线程竞相检查羊是不是被薅过?要是没有,就调用 shear! 方法。

以下结果是我在 MRI2.0 里多次执行得到的。

$ ruby check_then_set.rb
shearing...
$ ruby check_then_set.rb
shearing...
shearing...
$ ruby check_then_set.rb
shearing...
shearing...

有的时候一只羊被薅了两回。

如果你有 GIL 是你的代码在多线程面前一马平川,赶快忘了吧。 GIL 不能做出这样的担保。需要注意第一次运行时产生了预期结果。随后的几次运行,意外的结果才出现。如果继续试几次,会看到不同的变化。

这些意外的结果归咎于 Ruby 代码中的竞争条件。这实际上是一个足够典型的竞争条件,这一模式被称为:检查-后-设置 竞争条件。在检查-后-设置竞争条件中,两个以上线程检查某值,之后设置基于这个值的一些状态。在没有提供原子性的情况下,很有可能两个线程竞争通过"检查"阶段,之后一同执行"设置"阶段。

认出竞争条件

记得吗,上下文切换可以发生在代码的任何一行上。当一个线程切换到另一个线程时,想象你的程序被切分了一组互不关联的块。有序的一组块就是一组交错。

一种极端情况是,每行代码后面都可能都发生了上下文切换!这组交错会将每行代码穿插起来。另一种极端是,线程体中可能并没有发生上下文切换,这组交错会为每个线程保持各自原来代码的顺序。

一些交错是无害的。不是没行代码都会进入竞争条件。但是把你的程序想象成一组可能的交错可以帮助你辨识到什么时候竞争竞争条件确实发生了。我会用一系列图示来展现:这段代码可能被两个 Ruby 线程交错的情况。

为了使图示简单明了,我将shear!方法调用替换成了其方法体。

考虑这个图示:红色标注的代码是线程 A 中的一组交错,蓝色标出的是线程 B 的一组交错。

现在让我们模拟上下文切换来看一下代码是怎么被穿插起来的。最简单的情况是在运行中的线程没有被中断过。这样就没有竞争条件并会产生我们预期的输出。看起来就像是这样。

如图中所示这是一系列的有序事件组成的。注意 GIL 锁环绕着 Ruby 代码,所以两个线程不能真的并行跑。事件是有序的,从上到下依次发生。

在这样的交错中,线程 A 做完了它所有的工作,之后线程调度器触发了一个上下文切换到线程 B。由于线程 A 已经薅完了羊毛并更新了shorn变量,线程 B 其实什么也没做。

但事情不总是这样简单。注意线程调度器可以在这块代码的任意一点触发上下文切换。这次只是我们运气好而已。

来看看更凶残一些的例子,这回会产生意外的输出。

在这样的交错时,上下文切换真发生在会产生问题的地方。线程 A 检查了条件并且开始薅羊毛了。之后线程调度器调度了一个上下文切换,线程 B 上位了。尽管线程 A 已经执行了薅羊毛的工作,但尚未有机会更新shorn属性,于是线程 B 对此一无所知。

线程 B 自己也检查了条件,发现是false,又薅了一回这只羊。一旦其完成了,线程 A 又被调度回来,完成执行。即使线程 B 在执行期间已经通过代码设置了shorn = true,线程 A 也需要在做一遍,因为它就是在这退出又恢复的。

一只羊被薅两次也没什么大不了的,但是试将羊替换成发票,薅羊毛替换成集款,那一些客户就该不 happy 了。

我会分享更多例子来阐述事物不确定性的本质。

这里加入了更多的上下文切换,于是每个线程占小步前进,而且来回切换。请注意这里展示的是理论上的情况,上下文切换可以发生在程序的任何一行。每次代码执行交错的发生也不尽相同,所以这一次它可能会得到预期结果,下回就可能得到意外结果。

这真的是思考竞争条件的一种好方法。当你执笔多线程代码时,你需要考虑到程序可能背怎样切开和穿插,并产生多种多样的交错。如果一些交错貌似会带来错误的结果,你也许就要重新考虑解决问题的方法或者是用Mutex引入同步机制。

此刻正应告诉你用Mutex引入同步机制可以使示例代码线程安全。这是真的,你可以试一下。但我有意举例证明这一观点,这些槽糕的代码可不要用在多线程环境中啊。

无论何时你有多个线程共享一个对象引用时,并对其做了修改,你就要有麻烦了,除非有锁来阻止修改中的上下文切换。

然而,不在代码中使用显示锁,也能简单解决这种特定的竞争条件。这里有个Queue的解决方案。

require 'thread'

class Sheep
  # ...
end

sheep = Sheep.new
sheep_queue = Queue.new
sheep_queue << sheep

5.times.map do
  Thread.new do
      begin
        sheep = sheep_queue.pop(true)

        sheep.shear!
      rescue ThreadError
        # raised by Queue#pop in the threads
        # that don't pop the sheep
      end
  end
end.each(&:join)

因为没有变化,我忽略了Sheep的实现。现在,不在是每个线程共享 sheep 对象并竞争去薅它,队列提供了同步机制。

用 MRI 或其他真正并行的 Ruby 实现来运行,这段程序总会返回预期的结果。我们已经消除了这段代码的竞争条件。即使所有的线程可能多多少少会在同一时间掉用 Queue#pop,但是其内部使用了 Mutex 来保证只有一个线程可以得到羊。

一旦这个线程得到了羊,竞争条件就消失了。这个线程也没有竞争对手了!

我建议使用 Queue 作为锁的替代品,是因为它只简单地正确利用了队列。众所周知锁是很容易出错的。一旦使用不当,它们就会带来像死锁和性能下降这样的担忧。利用依赖抽象的数据结构。它严格包装了复杂的问题,提供简便的 API。

惰性初始化

我会一笔带过:惰性初始化是检查-后-设置另一种形式的竞争条件。||=操作符实际上扩展为:

@logger ||= Logger.new

# expands to

if @logger == nil
  @logger = Logger.new
end

@logger

反思

GIL 保证了 MRI 中 C 实现的原生 Ruby 方法执行的原子性(即便有些警告)。这一行为有时可以帮助作为 Ruby 开发者的我们,但是 GIL 其实是为了保护 MRI 内部而设计的,对 Ruby 开发者没有可靠的 API。

所以 GIL 不能解决线程安全的问题。就像我说的,使多线程编程正确很难,但是我们每天都在解决棘手的问题。我们面对棘手问题的方法之一是良好的抽象。

举例来说,当我的代码需要发一个 HTTP 请求时,我需要用一个套接字。但我并不直接使用套接字,这样既笨重又容易出错。相反,我使用一个抽象。一个 HTTP 客户端来提供更具体,简便的 API,把与套接字的交互和相关边界问题隐藏起来。

如果程序里增加一个线程,可能同时增加 5 个新 Bug。

我们谈论这些不特定于 Ruby 或者 MRI。这是一个多核编程的真实世界。我们设备上的核数只会越来越多,MRI 仍然在寻找解决方案。尽管它的保证,GIL 限制并行执行的方向似乎是错误的。这也是 MRI 的成长的烦恼吧。其他实现,如 JRuby 和 Rubinius 中已经没有了 GIL,实现了真正的并行。

目前,Ruby 开发者应该在这些问题上自我提高!了解并发。警惕竞争条件。以交错方式思考代码可以帮助你研究竞争条件。

别用状态共享通信,用通信共享状态。

看看扩展版本,然后想象一下交错在哪会发生。在多线程和无同步的情况下,@logger 确实可能被初始化两次。再此强调,两次创建 Logger 可能没什么的,但是我见过一些 Bug 像是 就是这个原因造成的。

Ruby 实例说明 Ruby 多线程的潜力和弱点
Ruby并发与线程
Ruby 无人知晓的 GIL
Nobody understands the GIL
Nobody understands the GIL - Part 2: Implementation
in the wild