Q:

rubyの組み込みメソッドがスレッドセーフか否かについて

以下のrubyのコードがrubyのバージョンによって挙動が違います。
これについて、公式のドキュメントはありますでしょうか?
また、組み込みメソッドがスレッドセーフかどうかは何を参照すれば良いでしょうか?
THREAD_NUM = 3

Array.new(THREAD_NUM) do
  Thread.new do
    10.times do
      puts "hoge"
    end
  end
end.each(&:join)

puts "-"*5

Array.new(THREAD_NUM) do
  Thread.new do
    10.times do
      print "hoge\n"
    end
  end
end.each(&:join)

$ ruby -v
ruby 2.4.1p111 (2017-03-22 revision 58053) [x86_64-darwin16]
$ruby thread.rb
hoge
hoge
hoge
hoge
hogehoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge

hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
-----
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge

$ ruby -v
ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-darwin16]
$ ruby thread.rb
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
-----
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge
hoge

A:

この回答は公式のCでの実装であるCRubyについてです。JRuby等でも同じとは限らない事に注意してください。
現在の所、CRubyの実装上のほとんどのメソッドはスレッドセーフに見えます。しかし、ThreadやMutex等のスレッド関係を除き、スレッドセーフであると期待してはいけません。スレッドセーフではないという前提でコードを実装すべきです。
現在のCRubyのスレッドに関する戦略は、個々のメソッドのCレベルでの実装でスレッドセーフであるかは考慮しないとしています。これは、

スレッドセーフのコードは複雑になり、バグ無く維持していくのが難しい
シングルスレッドでは余計なロック処理によって速度低下が発生する
Cライブラリのほんとんどがスレッドセーフではない

などが理由です。かといって、これをそのままマルチスレッドにした場合、segmentation faultで落ちます。そのため、Rubyではグローバルインタープリンタロック(Global Interpreter Lock, 以下GIL, RubyではグローバルVMロックとも言われる)という仕組みを採用しています(CPythonも採用しています)。これは、マルチスレッドであっても(Cレベルでは)動いているコードは常に一つにするという制限をかけるものです。動いているスレッドは常に一つであるため、スレッドセーフではないコードであっても、その処理自体はスレッドセーフのように動作が保証できるというものです。これが冒頭で書いた「スレッドセーフに見えます」と言っているところです。
ただ、これはCレベルでの処理においてオブジェクトを保護するための機能に過ぎません。ブロックや他のメソッド呼び出しなどで一旦Rubyのコードに移ってしまえば、そこでスレッドの切り替えの発生があり得ます。また、Cレベルでスレッドセーフな部分があれば、GILを外してもいいため、その時は同時に別スレッドが動作している場合もあります。つまり、そのような場合に、そのメソッドの処理の途中で他のスレッドに割り込みされる可能性があるため、アトミックな操作であるという保証はなくなってしまうということです。
参考:
マルチスレッド/プロセスまとめ（Ruby編）
Rubyのスレッド周りの話
Rubyでスレッドセーフでないことを簡単に確認したい - もょもとの技術ノート

今回のコードのputsがどうなっているかという話をしましょう。putsは最終的に$stdoutに対してIO#writeを呼び出すという形なっています。IO#writeを上書きしたら、putsの動作も変わります。次のコードを見てください。
class IO
    alias :write_org :write
    def write(str)
        write_org("[+++]")
        write_org(str)
        write_org("[---]")
    end
end

puts "hoge"

実行すると
[+++]hoge[---][+++]
[---]

となります。つまり、IO#writeを二回呼び出しています。この呼び出しは一旦Rubyの処理に戻りますので、この瞬間スレッドの切り替えが発生する可能性があります。もし、このタイミングで切り替えが発生した場合は、別の物に割り込まれる形になるでしょう。
また、IO#writeのような処理では内部でのIO書き込み時にGILは外すように実装されています。IO処理は重いため、その間別のスレッドが走るようにするためです。そうなると、この外れている僅かな時間でもう一つのスレッドが走り、割り込むような形で入る可能性があります。
スレッド切り替えはあくまで可能性であって、バージョン以外にも環境によっても左右されます。起きる場合もあれば全然起きない場合もあります。文字列をもっと長くすれば、発生する可能性が増えることでしょう。
※ スレッド切り替えはIO処理の他に時間経過による切り替えがありますが、それが発生するのはRubyレベルでの処理があった時になります。Cレベルでの処理中は切り替えは発生しません。
参考:
第19章 スレッド
その他にスレッドセーフではない例を一つ書いておきます。
class String
  alias :plus :+
  def +(other)
    Thread.pass
    self.plus(other)
  end
end

arr0 = ["0"] * 10
arr1 = ["1"] * 10
arr = []
arr.replace(arr0)
ths = []

ths << Thread.new do
  10.times do
    arr.replace(arr0)
    Thread.pass
  end
end

ths << Thread.new do
  10.times do
    arr.replace(arr1)
    Thread.pass
  end
end

ths << Thread.new do
  10.times do
    puts arr.sum("")
    Thread.pass
  end
end

ths.each(&:join)

もし、Array#sumがスレッドセーフであれば、"0000000000"か"1111111111"が出力されますが、実際は0と1が混じった文字列が出力される場合があります。

