Ruby 异常处理

默认情况下,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!

此程序的工作原理如下:

  1. 主线程首先执行puts "Waiting for thread..."
  2. 然后它遇到thread.join。结果,它会等待子线程(存储在thread变量中)完成。
  3. 子线程中,首先执行puts "Thread starting..."
  4. 然后子线程会因为sleep(2)而暂停两秒钟。
  5. 暂停后,执行puts "Thread done!"
  6. 由于子线程已完成执行,程序会返回到主线程
  7. 最后,主线程执行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!

在这里,我们创建了三个线程(thread1thread2thread3)并等待它们全部完成。

请注意,我们是单独创建线程的,导致了代码的冗长。

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

在这里,我们创建了一个名为mutexMutex对象。然后,当线程访问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限制了性能)。
  • 如果您需要真正的并行性。在这种情况下,请考虑使用进程或外部库,如parallelconcurrent-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)

在这里,numthread1作用域内的局部变量。因此,我们无法在线程外部访问它。

在线程末尾使用join方法

您可以将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?方法检查线程状态

我们可以使用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方法获取线程的返回值

我们可以使用value方法来获取线程的最后求值表达式。例如:

thread = Thread.new do
  5 * 2
end

puts thread.value

# Output: 10

在这里,thread.value给我们提供了线程操作5 * 2的结果,即10

注意:使用thread.value时,线程会阻塞直到结果可用。这与调用join然后检索结果类似。

你觉得这篇文章有帮助吗?

我们的高级学习平台,凭借十多年的经验和数千条反馈创建。

以前所未有的方式学习和提高您的编程技能。

试用 Programiz PRO
  • 交互式课程
  • 证书
  • AI 帮助
  • 2000+ 挑战