自分が関わったRuby 2.5に導入予定の新機能について書きます。
Hash#slice
Redmineのチケット: https://bugs.ruby-lang.org/issues/8499
Hash#sliceは、Hashの特定のエントリ(key/value)を抽出するメソッドです。
{a: 1, b: 2}.slice(:a) #=> {:a=>1}
ActiveSupport版とRuby 2.5版の挙動の違い
このメソッドはActiveSupportから輸入されたものですが、レシーバがHashのサブクラスである場合の挙動はActiveSupport版とは異なっています。
ActiveSupport版のHash#sliceは、レシーバがHashのサブクラスである場合そのサブクラスを返します。
require 'active_support'
require 'active_support/core_ext'
class SubHash < Hash; end
sub_hash = SubHash[a: 1, b: 2]
slice = sub_hash.slice(:a) #=> {:a=>1}
slice.class #=> SubHash
一方Ruby 2.5で導入されるHash#sliceはレシーバがHashのサブクラスであってもHashを返します。
class SubHash < Hash; end
sub_hash = SubHash[a: 1, b: 2]
slice = sub_hash.slice(:a) #=> {:a=>1}
slice.class #=> Hash
なぜこのようになっているかというと、Rubyとして他のメソッドとの一貫性を重視したためです。
Hashをフィルタするメソッドとして他に#selectや#rejectがありますが、実はこれらのメソッドはHashのサブクラスがレシーバであってもHashを返します。
class SubHash < Hash; end
sub_hash = SubHash[a: 1, b: 2]
sub_hash.select {}.class #=> Hash
sub_hash.reject {}.class #=> Hash
SubHashを#selectや#rejectしてHashが返ってくるのは違和感があると思いますが、
#selectや#rejectの挙動を変えるには#sliceの導入とは別の議論が必要なので、現時点では#sliceの挙動はHashを返す方に寄せています。
Rails側での対応
上記の通りRuby 2.5に導入されるHash#sliceはサブクラスの扱いが異なっているので、Rails側でRuby 2.5に対応するための修正が必要になりましたが、 a_matsuda さんが必要な修正をcommitしてくださいました。
IO#writeが複数の引数を取るようになった
Redmineのチケット: https://bugs.ruby-lang.org/issues/9323
IO#writeが複数の引数を取るようになりました。
writeに複数の引数が渡された場合、可能であればwritev(2)を利用して渡された引数をアトミックに書き出すよう試みます。
% strace ruby2.5 -e '$stdout.write("a", "b")'
(略)
writev(1, [{"a", 1}, {"b", 1}], 2) = 2
(略)
実はこっそり使われていたwritev
実は、writevはRuby 2.2からこっそり使われていました (commit)。
writeによってIOのバッファが溢れた場合、writevを使ってバッファとバッファに収まりきらなかったStringを同時に書き出そうと試みます。
これにより、バッファが溢れてしまうようなwriteであっても発行するシステムコールは1回で済みます。
2.2以降のRubyがwritevを使う挙動は以下のコードで確かめることができます。
str = "a" * 4000
File.open("test", "w") do |f|
3.times do # IOの書き込みバッファは8192バイトなので、3回目のwriteでバッファが溢れる
f.write(str)
end
end
% strace ruby write.rb
(略)
writev(7, [{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 8000}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4000}], 2) = 12000
(略)
この挙動については大江戸Ruby会議 05の資料でも解説しています。
writeに複数の引数を渡した場合の挙動
Ruby 2.5でwriteに複数の引数を渡せるようになっても、通常ファイルの場合の挙動はあまり変わりません。
通常ファイルの場合バッファリングが行われる上に、前節で見たようにバッファが溢れた場合は以前からwritev(2)が使われるようになっているからです。
以下のコードのようにwriteに3つのStringを渡してみても、writev(2)の呼ばれ方は3回writeした場合と変わっていないことがわかります。
str = "a" * 4000
File.open("test", "w") do |f|
f.write(str, str, str)
end
% strace ruby2.5 write.rb
writev(7, [{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 8000}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4000}], 2) = 12000
挙動が変わるのは主にIOがTTYである場合、同期モードである場合、Socketである場合です。
TTYの場合
バッファリングされることなく、writev(2)に"a"と"b"が別々に渡されます。
% strace ruby2.5 -e '$stdout.write("a", "b")'
(略)
writev(1, [{"a", 1}, {"b", 1}], 2) = 2
(略)
同期モードの場合
writeに3つのStringを渡していますが、IOが同期モードであるためにバッファリングされることなくwritevに3つの引数が渡されます。
str = "a" * 4000
File.open("test", "w") do |f|
f.sync = true
f.write(str, str, str)
end
% strace ruby2.5 write.rb
(略)
writev(7, [{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4000}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4000}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 4000}], 3) = 12000
(略)
Socketの場合
UDP
1引数のwriteを2回繰り返した後に2引数のwriteを行なっています。
2引数のwriteではwritevが使われていることがわかります。
require "socket"
str = "a" * 100
s = UDPSocket.new
s.connect("10.12.2.211", 1234)
s.write(str)
s.write(str)
s.write(str, str)
% strace ruby2.5 udp.rb
(略)
write(7, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100) = 100
write(7, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100) = 100
writev(7, [{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100}], 2) = 200
(略)
tcpdumpで見てみると、writeを2回呼んだ場合はパケットが2つに分かれていますが、2引数でwriteを呼んだ場合はwritevが使われて1つのパケットにまとめられていることがわかります。
% sudo tcpdump -i ens3 -n udp port 1234
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on ens3, link-type EN10MB (Ethernet), capture size 262144 bytes
22:49:08.740243 IP 10.12.2.204.36346 > 10.12.2.211.1234: UDP, length 100
22:49:08.740316 IP 10.12.2.204.36346 > 10.12.2.211.1234: UDP, length 100
22:49:08.740340 IP 10.12.2.204.36346 > 10.12.2.211.1234: UDP, length 200
TCP
TCPの場合でも、TCP_NODELAYを有効にしている場合などでは効果が見込めます。
次の例では、writeに複数の引数を渡すことによってパケットが1つにまとめられています。
require "socket"
str = "a" * 100
s = TCPSocket.open("10.12.2.211", 1234)
s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) # TCP_NODELAYを指定
s.write(str)
s.write(str)
s.write(str, str)
% strace ruby2.5 tcp.rb
write(9, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100) = 100
write(9, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100) = 100
writev(9, [{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100}, {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"..., 100}], 2) = 200
% sudo tcpdump -i ens3 -n port 1234
(略)
22:45:28.805352 IP 10.12.2.204.39106 > 10.12.2.211.1234: Flags [P.], seq 1:101, ack 1, win 229, options [nop,nop,TS val 3263095302 ecr 380867644], length 100
22:45:28.805400 IP 10.12.2.204.39106 > 10.12.2.211.1234: Flags [P.], seq 101:201, ack 1, win 229, options [nop,nop,TS val 3263095302 ecr 380867644], length 100
22:45:28.805419 IP 10.12.2.204.39106 > 10.12.2.211.1234: Flags [P.], seq 201:401, ack 1, win 229, options [nop,nop,TS val 3263095302 ecr 380867644], length 200
(略)
アプリケーションによっては、writeに複数の引数を渡してパケット数をまとめることでパフォーマンス上のメリットがあるかもしれません。
IO.copy_streamでcopy_file_range(2)が利用されるようになった
Redmineのチケット: https://bugs.ruby-lang.org/issues/13867
IO.copy_streamは、IO間のコピーを行うメソッドです。
IO.copy_stream(src, dst)
Ruby 2.4までの実装では、可能であればsendfile(2)を使ったコピーを試み、sendfileが使えなければread/writeを繰り返してコピーを行う実装になっていました。
Ruby 2.5では、可能であればLinux 4.5から導入されたcopy_file_range(2)というシステムコールを使ってコピーのオフロードを試みるようになりました。
どのようなオフロードが行われるかはファイルシステムによって異なりますが、現時点ではNFSの場合はサーバーサイドでのコピー、reflinkが利用可能なファイルシステムではreflinkによるオフロードが行われます。