LoginSignup
13
4

More than 5 years have passed since last update.

Ruby 2.5の新機能

Last updated at Posted at 2017-12-14

自分が関わった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のサブクラスである場合そのサブクラスを返します。

ActiveSupportのHash#slice
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を返します。

Ruby2.5のHash#slice
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を使う挙動は以下のコードで確かめることができます。

write.rb
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した場合と変わっていないことがわかります。

write.rb
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つの引数が渡されます。

write.rb
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が使われていることがわかります。

udp.rb
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つにまとめられています。

tcp.rb
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によるオフロードが行われます。

13
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4