默认情况下,Ruby程序一次只运行一个任务。这意味着如果代码的某一部分需要很长时间才能完成,其余部分就必须等待。
但是,如果我们想同时做多件事情,比如在显示加载动画的同时下载文件呢?
这就是多线程的作用所在。
什么是线程?
线程就像一个在Ruby程序内部运行的迷你程序。通过多线程,我们可以同时运行多个线程。
在Ruby中,我们可以使用Thread
类来创建线程。
在Ruby中创建线程
我们使用Thread.new
方法来创建一个新线程。它的语法是这样的:
Thread.new do
# Code to run in a new thread
end
这种语法会在我们的主程序内部创建一个子线程。
但是在我们使用这种语法创建实际线程之前,让我们先了解一下主线程和子线程的区别。
主线程 vs 子线程
当我们运行一个Ruby程序时,它总是从一个主线程开始。我们使用Thread.new
创建的任何线程都是子线程。例如:
# Create a new thread (child thread)
new_thread = Thread.new do
puts "Welcome to a new thread!"
puts "Exiting the thread..."
end
# Main thread starts here
# Wait for the child thread to finish
new_thread.join
# Print a message
puts "Welcome to the main program."
输出
Welcome to a new thread! Exiting the thread... Welcome to the main program.
在这里,我们创建了一个名为new_thread
的子线程,它会打印一组消息。
join
方法告诉Ruby在继续执行主线程之前等待该线程完成。
提示:尝试从这个程序中删除join
方法。您会看到执行会立即继续到主程序。
带sleep的并发线程
我们可以使用sleep
方法将我们的线程暂停指定的时间。让我们使用它来使主线程和子线程并发执行:
# Child thread
thread = Thread.new do
# Loop code 3 times
3.times do
puts "Child thread working..."
# Pause the thread for 1 second
sleep(1)
end
end
# Main thread
# Loop code 3 times
3.times do
puts "Main thread working..."
# Pause the thread for 1 second
sleep(1)
end
# Wait for the child thread to end
thread.join
示例输出
Main thread working... Child thread working... Main thread working... Child thread working... Main thread working... Child thread working...
在这里,主线程和子线程各自执行times
循环三次。在循环的每次迭代中,线程会暂停一秒钟。
1. 'join'方法的作用
请注意,我们在程序的末尾才使用了join
方法。所以,主线程在等待子线程完成之前就开始执行自己的循环。
2. 'sleep'方法的作用
由于主线程的循环中使用了sleep
方法,子线程将在暂停之间执行。
同样,子线程中的sleep
方法确保了主线程在暂停之间执行。
3. 并发线程
因此,主线程和子线程是并发执行的(交错执行)。这意味着来自主线程和子线程的输出会混合出现(而不是先输出一个线程的所有内容,然后再输出另一个线程的所有内容)。
注意事项
- Ruby不保证精确的交错或计时。线程调度取决于Ruby实现和操作系统。
- 在标准Ruby(MRI)中,全局解释器锁(GIL)会阻止CPU密集型任务的真正并行执行。但是,线程对于I/O密集型任务(即与输入/输出相关的任务)仍然很有用。
- 您可以删除程序末尾的
thread.join
,它仍然可以工作。但是,不能保证主线程会等待子线程完全执行。因此,使用join
更安全。
示例1:基本的Ruby多线程
thread = Thread.new do
puts "Thread starting..."
sleep(2)
puts "Thread done!"
end
puts "Waiting for thread..."
thread.join
puts "All done!"
输出
Waiting for thread... Thread starting... Thread done! All done!
此程序的工作原理如下:
- 主线程首先执行
puts "Waiting for thread..."
。 - 然后它遇到
thread.join
。结果,它会等待子线程(存储在thread
变量中)完成。 - 在子线程中,首先执行
puts "Thread starting..."
。 - 然后子线程会因为
sleep(2)
而暂停两秒钟。 - 暂停后,执行
puts "Thread done!"
。 - 由于子线程已完成执行,程序会返回到主线程。
- 最后,主线程执行
puts "All done!"
。
示例2:运行多个线程
thread1 = Thread.new do
puts "Thread 1 starting"
sleep(1)
puts "Thread 1 finished"
end
thread2 = Thread.new do
puts "Thread 2 starting"
sleep(1)
puts "Thread 2 finished"
end
thread3 = Thread.new do
puts "Thread 3 starting"
sleep(1)
puts "Thread 3 finished"
end
# Wait for all threads to finish
thread1.join
thread2.join
thread3.join
puts "\nAll threads finished!"
示例输出
Thread 1 starting Thread 2 starting Thread 3 starting Thread 1 finished Thread 2 finished Thread 3 finished All threads finished!
在这里,我们创建了三个线程(thread1
、thread2
和thread3
)并等待它们全部完成。
请注意,我们是单独创建线程的,导致了代码的冗长。
thread1 = Thread.new do
# Code for thread1
end
thread2 = Thread.new do
# Code for thread2
end
thread3 = Thread.new do
# Code for thread3
end
我们可以通过在循环中创建线程来解决这个问题。让我们在下一个示例中看看如何做到这一点。
示例3:使用循环和数组创建多个线程
让我们通过在循环中创建线程并将它们添加到数组中来编写一个更简洁的版本。
# Create an array to store multiple threads
threads = []
# Run a loop 3 times to create 3 separate threads
3.times do |i|
# Create a new thread and
# add it to the threads array
threads << Thread.new do
puts "Thread #{i + 1} starting"
sleep(1)
puts "Thread #{i + 1} finished"
end
end
# Iterate through the array
# Wait for each thread to execute
threads.each(&:join)
puts "\nAll threads finished!"
示例输出
Thread 1 starting Thread 2 starting Thread 3 starting Thread 2 finished Thread 1 finished Thread 3 finished All threads finished!
1. 使用循环创建线程
首先,我们运行了一个times
循环三次来创建三个线程。在循环的每次迭代中,都会创建一个新线程并将其附加到threads
数组。
3.times do |i|
threads << Thread.new do
# Thread code
end
end
2. 主线程
在主线程中,我们使用each
循环遍历threads
数组。
在循环的每次迭代中,我们使用join
来确保线程被完整执行。
threads.each(&:join)
上面的代码是以下代码的简写:
threads.each { |thread| thread.join }
您可以用上面传统的each
循环代码替换示例中的each
循环简写。
注意:此程序的输出可能因每次执行而异。
共享数据和竞态条件
所有线程共享相同的内存。因此,如果多个线程同时尝试修改同一个变量,可能会发生意外情况。例如:
# Create a counter variable
counter = 0
# Loop 3 times to create 3 threads
# And append them to the threads array
threads = 3.times.map do
Thread.new do
10.times do
# Increment counter
counter += 1
end
end
end
# Wait for the threads to execute
threads.each(&:join)
# Print the value of counter
puts "Counter: #{counter}"
预期输出
Counter: 30
可能的输出1
Counter: 26
可能的输出2
Counter: 29
在这里,我们在主线程中创建了counter
变量,并将其初始化为0。因此,该变量将被所有子线程共享。
1. 创建线程
然后,我们使用以下代码创建了三个子线程:
threads = 3.times.map do
Thread.new do
# Code
end
end
我们使用map
来创建一个线程数组,通过收集每个Thread.new
调用的结果。
每个线程都会运行自己的times
循环,包含10次迭代。在循环的每次迭代中,counter
变量的值会增加1。
Thread.new do
10.times do
# Increment counter
counter += 1
end
end
2. 理想情况
理想情况下,每个线程的执行不受其他线程的干扰,并以以下方式更新counter
变量:
线程 | 循环操作 | counter(最终值) |
---|---|---|
1 | 将counter 的值增加10次。 |
10 |
2 | 将counter 的值增加10次。 |
20 |
3 | 将counter 的值增加10次。 |
30 |
3. 实际情况
实际上,多个线程可能尝试同时更新counter
变量。
例如,如果第一个线程将counter
的值更新为6,那么另一个线程也可能同时将其值更新为6。
因此,第一个线程的更新会被覆盖,而不是第一个线程将counter
更新为6,然后另一个线程将其更新为7。
在这种情况下,counter
将被更新为6,最终输出将小于30。
注意:上述场景被称为竞态条件,当多个线程尝试读取、修改和写入共享资源而没有协调时。
在Ruby中,我们可以使用Mutex
来防止竞态条件。
使用Mutex防止竞态条件
为了防止竞态条件,我们使用Mutex
(互斥),它会锁定一段代码,使一次只有一个线程可以运行它。
您可以将Mutex
想象成浴室门上的锁。一次只有一个人(线程)可以进入。
通常,我们使用Mutex
来控制对共享资源的访问,当多个线程并发运行时。
语法
mutex_object = Mutex.new
# Other code
mutex_object.synchronize do
# Code that accesses shared resources
end
示例4:Ruby Mutex
现在,让我们使用Mutex
来防止我们之前的程序中发生竞态条件:
counter = 0
# Create a Mutex object
mutex = Mutex.new
threads = 3.times.map do
Thread.new do
10.times do
# Update counter inside the Mutex block
mutex.synchronize do
counter += 1
end
end
end
end
threads.each(&:join)
puts "Counter: #{counter}"
输出
Counter: 30
在这里,我们创建了一个名为mutex
的Mutex
对象。然后,当线程访问counter
变量时(因为它是一个共享资源),我们使用此对象来锁定该线程。
mutex.synchronize do
counter += 1
end
因此,当一个线程到达代码的这一部分时,它会尝试锁定mutex.synchronize do
内的代码块。
- 如果
mutex
已被另一个线程锁定,则当前线程将等待直到它被解锁。 - 一旦获取(锁定)
mutex
,线程将执行代码块并将counter
增加1。 - 代码块完成后,
mutex
会被自动解锁,允许其他等待的线程获取它。
由于没有线程干扰另一个线程的执行,我们总是会得到以下输出:
Counter: 30
处理线程中的错误
如果在线程内部发生错误,它不会使主程序崩溃,但该线程会停止。例如:
# A thread that raises an error
Thread.new do
raise "Something went wrong"
end
# Pause main thread for 1 second
# So that the child thread can execute
sleep(1)
puts "Main program continues"
输出
#<Thread:0x00007c501ef63bd8 /tmp/ZXZLrHrmDX/main.rb:1 run> terminated with exception (report_on_exception is true): /tmp/ZXZLrHrmDX/main.rb:2:in 'block in <main>': Something went wrong (RuntimeError) Main program continues
在这里,我们创建了一个线程,它使用raise
关键字手动引发了一个错误。这会停止线程的执行。
您可以使用begin...rescue
来处理此类异常。
Thread.new do
# Put code that may cause error inside 'begin'
begin
raise "Error in thread"
# Rescue the error using object 'e'
rescue => e
puts "Caught error: #{e.message}"
end
end
sleep(1)
puts "Main program continues"
输出
ERROR! Caught error: Error in thread Main program continues
在这里,我们将可能导致错误的代码保留在begin
语句中。如果发生错误,rescue
块会通过打印错误消息来处理异常。
这样,即使线程出现错误,其执行也不会突然停止。
如需了解更多信息,请访问 Ruby 异常处理。
更多关于Ruby多线程
1. 何时使用线程
当您想做以下事情时,请使用线程:
- 同时执行多个任务。
- 避免阻塞主程序(例如,在长时间运行的任务中)。
- 提高I/O密集型程序的性能。
注意:Ruby使用全局解释器锁(GIL),这限制了CPU密集型任务的真正并行化程度。但线程对于等待输入/输出的任务(如文件操作、网络请求或数据库访问)仍然有帮助。
2. 何时不要使用线程
- 对于Ruby中的重度CPU密集型任务(GIL限制了性能)。
- 如果您需要真正的并行性。在这种情况下,请考虑使用进程或外部库,如
parallel
或concurrent-ruby
。
- 始终使用
join
来等待线程完成。 - 除非使用线程安全的方法,否则避免共享变量。
- 使用
Mutex
来安全访问共享数据。 - 处理线程内的异常。
- 保持线程逻辑简单明了。
进程有自己的内存,而线程在同一进程中共享内存。
而且,虽然线程更轻量级且创建速度更快,但它们不像进程那样提供真正的并行性。
虽然线程共享在主线程中声明的变量和资源,但它们不共享在线程内部声明的变量。
换句话说,在线程内部声明的变量是该线程私有的。例如:
thread1 = Thread.new do
# Create a local variable 'num'
num = 5
puts num
end
# Invalid: Attempt to access 'num' in another thread
thread2 = Thread.new do
puts num
end
thread1.join
thread2.join
# Invalid: Attempt to access 'num' in main thread
puts num
输出
5 #<Thread:0x00007ddbefa53978 /tmp/XjZ6AVlftj/main.rb:8 run> terminated with exception (report_on_exception is true): /tmp/XjZ6AVlftj/main.rb:9:in 'block in <main>': undefined local variable or method 'num' for main (NameError)
在这里,num
是thread1
作用域内的局部变量。因此,我们无法在线程外部访问它。
您可以将join
方法附加到线程的end
语句。例如:
thread1 = Thread.new do
puts "First thread"
end.join
thread2 = Thread.new do
puts "Second thread"
end.join
puts "Main thread"
输出
First thread Second thread Main thread
正如您所见,我们使用end.join
来让程序等待线程执行。这种语法在短线程块中很常见,有助于减少混乱。
上述程序等同于
thread1 = Thread.new do
puts "First thread"
end
thread2 = Thread.new do
puts "Second thread"
end
thread1.join
thread2.join
puts "Main thread"
我们可以使用alive?
方法来检查线程的状态。例如:
thread = Thread.new do
sleep(2)
end
puts "Thread alive? #{thread.alive?}" # Output: true
sleep(3)
puts "Thread alive? #{thread.alive?}" # Output: false
在这里,thread
在结束前会暂停两秒钟。因此,它将在两秒钟内“存活”(运行或睡眠)。
由于子线程正在睡眠,我们在主线程中检查它是否存活。检查方法如下:
1. 第一次检查
当我们第一次检查thread
时,它仍然在运行,因为两秒钟还没有过去。因此,thread.alive?
返回true
。
2. 第二次检查
第一次检查后,主线程会睡眠三秒钟。在这段时间里,子线程已经完成了它的执行。
因此,第二次thread.alive?
返回false
。
我们可以使用value
方法来获取线程的最后求值表达式。例如:
thread = Thread.new do
5 * 2
end
puts thread.value
# Output: 10
在这里,thread.value
给我们提供了线程操作5 * 2
的结果,即10。
注意:使用thread.value
时,线程会阻塞直到结果可用。这与调用join
然后检索结果类似。