$Sequence 関数について
この記事では $Increment 関数と $Sequence 関数を比較します。
まずは、$Increment 関数を聞いたことがないという方のために、その概要を説明いたします。 $Increment は、CachéObjectScript の関数で、引数をアトミックに 1 ずつインクリメントし、結果の値を返します。 $Increment にパラメーターとして渡せるのはグローバル変数ノードとローカル変数ノードのみで、任意の式を渡すことはできません。 $Increment は連続する ID の割り当てに多用されます。 その場合、$Increment のパラメーターにはグローバルノードがよく使用されます。 $Increment を使用するプロセスには確実に任意の ID が割り当てられます。
for i=1:1:10000 {
set Id = $Increment(^Person) ; 新しい ID
set surname = ##class(%PopulateUtils).LastName() ; ランダムなラストネーム
set name = ##class(%PopulateUtils).FirstName() ; ランダムなファーストネーム
set ^Person(Id) = $ListBuild(surname, name)
}
$Increment の問題は、多数のプロセスが並行して行を追加していると、各プロセスはそれぞれの順番が回ってくるまで、ID を持つグローバルノードの値をアトミックに変更することができない場合があるという点です。上の例では、^Person がこれに該当します。
この問題を解決するためにデザインされたのが新しい関数$Sequence です。 $Sequence は Caché 2015.1 から導入されています。 $Increment と同様に、$Sequence もそのパラメーターの値をアトミックにインクリメントします。 $Increment と違う点として、$Sequence は前のプロセスから後続のカウンター値を現在のプロセス用にリザーブし、リザーブしておいた範囲の次の値を同じプロセス内の次の呼び出し中に返します。 $Sequence はリザーブしておく値の数を自動的に計算します。 プロセスが $Sequence を呼び出す頻度が増えると、$Sequence がリザーブする値の数も増えていきます。
USER>kill ^myseq
USER>for i=1:1:15 {write "increment:",$Seq(^myseq)," allocated:",^myseq,! }
increment:1 allocated:1
increment:2 allocated:2
increment:3 allocated:4
increment:4 allocated:4
increment:5 allocated:8
increment:6 allocated:8
increment:7 allocated:8
increment:8 allocated:8
increment:9 allocated:16
increment:10 allocated:16
increment:11 allocated:16
increment:12 allocated:16
increment:13 allocated:16
increment:14 allocated:16
increment:15 allocated:16
$Sequence(^myseq) が 9 を返した時点で、次の 8 個の値 (16 まで) が既に現在のプロセス用にリザーブされています。 別のプロセスが $Sequence を呼び出すと、10 ではなく、17 という値が割り当てられます。
$Sequence は、複数のグローバルノードを同時にインクリメントするプロセスを考えてデザインされています。 $Sequence は値をリザーブするため、リザーブされたすべての値がプロセスで使用されない場合は、ID の間にギャップが生じる場合があります。 $Sequence を使用する主な目的は、連続する ID を生成することです。 $Increment は $Sequence よりもジェネリクス型の関数と言えます。
それでは、$Increment と $Sequence のパフォーマンスを比較してみましょう。
Class DC.IncSeq.Test
{
ClassMethod filling()
{
lock +^P:"S"
set job = $job
for i=1:1:200000 {
set Id = $Increment(^Person)
set surname = ##class(%PopulateUtils).LastName()
set name = ##class(%PopulateUtils).FirstName()
set ^Person(Id) = $ListBuild(job, surname, name)
}
lock -^P:"S"
}
ClassMethod run()
{
kill ^Person
set z1 = $zhorolog
for i=1:1:10 {
job ..filling()
}
lock ^P
set z2 = $zhorolog - z1
lock
write "done:",z2,!
}
}
メソッド run
は 10 個のプロセスを実行し、それぞれが 200,000 件のレコードを ^Person の Global 変数ノードに挿入しています。 子プロセスの終了を待機するため、 メソッド run
は ^P に対して排他ロックを取得しようとします。 子プロセスがジョブを終了し、^P の共有ロックを開放すると、メソッド run は ^P の排他ロックを取得し、実行を続けます。 この直後に、システム変数 $zhorolog の時間を記録し、これらのレコードを挿入するのにかかった時間を計算します。 低速 HDD を搭載したマルチコアノートブックでは 40 秒かかりました(科学実験として、これまで数回実行していますが、今回で 5 回目です ):
USER>do ##class(DC.IncSeq.Test).run()
done:39.198488
この 40 秒間を分析すると興味深いことが分かりました。 ^%SYS.MONLBL を実行すると、ID の取得に合計 100 秒もかかっていることが分かります。10 個のプロセスに 100 秒かかっているということは、各プロセスが新しい ID を取得するのに 10 秒かかっているということです。ファーストネームとラストネームの取得には 1.7 秒、データグローバルにデータを書き込むのには 28.5 秒かかっています。
下に示す %SYS.MONLBL レポートの 1 列目は行番号、2 列目はこの行が実行された回数、3 列目はこの行を実行するのにかかった秒数を表しています。
; ** メソッド 'filling' のソースコード**
1 10 .001143 lock +^P:"S"
2 10 .000055 set job = $JOB
3 10 .000118 for i=1:1:200000 {
4 1998499 100.356554 set Id = $Increment(^Person)
5 1993866 10.409804 set surname = ##class(%PopulateUtils).LastName()
6 1990461 6.347832 set name = ##class(%PopulateUtils).FirstName()
7 1999762 285.54603 set ^Person(Id) = $ListBuild(job, surname, name)
8 1999825 3.393706 }
9 10 .000259 lock -^P:"S"
; ** メソッド 'filling' のソースコード終わり **
;
; ** メソッド 'run' のソースコード **
1 1 .005503 kill ^Person
2 1 .000002 set z1 = $zhorolog
3 1 .000002 for i=1:1:10 {
4 10 .201327 job ..filling()
5 0 0 }
6 1 43.472692 lock ^P
7 1 .00003 set z2 = $zhorolog - z1
8 1 .00001 lock
9 1 .000053 write "done:",z2,!
; ** メソッド 'run' のソースコード終わり**
プロファイリングが実行されたため、前回よりも 4 秒長くなり、合計で 43.47 秒かかりました。
テストコードの filling
メソッドで、1 つだけ入れ替えたい部分があります。 $Increment(^Person) を $Sequence(^Person) に変更して、もう一度テストを実行しましょう。
USER>do ##class(DC.IncSeq.Test).run()
done:5.135189
意外な結果になりましたね。 $Sequence によって ID を取得する時間は削減されましたが、データをグローバルに保管するのにかかっていた 28.5 秒はどうなったのでしょう。 では、^%SYS.MONLBL を確認してみましょう。
; ** メソッド 'filling' のソースコード **
1 10 .001181 lock +^P:"S"
2 10 .000026 set job = $JOB
3 10 .000087 for i=1:1:200000 {
4 1802473 1.996279 set Id = $Sequence(^Person)
5 1784910 4.429576 set surname = ##class(%PopulateUtils).LastName()
6 1853508 3.829051 set name = ##class(%PopulateUtils).FirstName()
7 1838752 32.281624 set ^Person(Id) = $ListBuild(job, surname, name)
8 1951569 1.0243 }
9 10 .000219 lock -^P:"S"
; ** メソッド 'filling' のソースコード終わり **
;
; ** メソッド 'run' のソースコード **
1 1 .006514 kill ^Person
2 1 .000002 set z1 = $zhorolog
3 1 .000002 for i=1:1:10 {
4 10 .385055 job ..filling()
5 0 0 }
6 1 6.558119 lock ^P
7 1 .000011 set z2 = $zhorolog - z1
8 1 .000008 lock
9 1 .000025 write "done:",z2,!
; ** メソッド 'run' のソースコード終わり **
各プロセスは 10 秒ではなく、わずか 0.2 秒で ID を取得できるようになりました。 よく解らないのは、なぜ各プロセスがたった 3.23 秒でデータを保管できているのか、という点です。 それは、グローバルノードはデータブロックに保管されるほか、通常は各ブロックのサイズが 8192 バイトだからです。 プロセスは、(set ^Person(Id) = …
のように) グローバルノードの値を変更する前に、ブロック全体をロックします。 複数のプロセスが同時に同じブロック内のデータを変更しようとすると、それを変更できるのは 1 つのプロセスに限られるので、他のプロセスはそれが終了するのを待たなくてはいけません。
$Increment を使って新しい ID を生成する際に作成されたグローバルを見てみましょう。 連続するレコードに同じプロセス ID が割り当てられることはまずありません (プロセス ID はデータリストの最初の要素として保管していることをお忘れなく)。
1: ^Person(100000) = $lb("12950","Kelvin","Lydia")
2: ^Person(100001) = $lb("12943","Umansky","Agnes")
3: ^Person(100002) = $lb("12945","Frost","Natasha")
4: ^Person(100003) = $lb("12942","Loveluck","Terry")
5: ^Person(100004) = $lb("12951","Russell","Debra")
6: ^Person(100005) = $lb("12947","Wells","Chad")
7: ^Person(100006) = $lb("12946","Geoffrion","Susan")
8: ^Person(100007) = $lb("12945","Lennon","Roberta")
9: ^Person(100008) = $lb("12944","Beatty","Mark")
10: ^Person(100009) = $lb("12946","Kovalev","Nataliya")
11: ^Person(100010) = $lb("12947","Klingman","Olga")
12: ^Person(100011) = $lb("12942","Schultz","Alice")
13: ^Person(100012) = $lb("12949","Young","Filomena")
14: ^Person(100013) = $lb("12947","Klausner","James")
15: ^Person(100014) = $lb("12945","Ximines","Christine")
16: ^Person(100015) = $lb("12948","Quine","Mary")
17: ^Person(100016) = $lb("12948","Rogers","Sally")
18: ^Person(100017) = $lb("12950","Ueckert","Thelma")
19: ^Person(100018) = $lb("12944","Xander","Kim")
20: ^Person(100019) = $lb("12948","Ubertini","Juanita")
並行プロセスが同じブロックにデータを書き込もうとしていたため、データの変更にかかる時間よりも待機していた時間の方が長くなっていました。 $Sequence を使用すると、ID はチャンク生成されるので、異なるプロセスが異なるブロックを使用する可能性が高くなります。
1: ^Person(100000) = $lb("12963","Yezek","Amanda")
// プロセス番号が 12963 のレコードは 351 件
353: ^Person(100352) = $lb("12963","Young","Lola")
354: ^Person(100353) = $lb("12967","Roentgen","Barb")
実際のプロジェクトがこのサンプルのような状態に陥っているという方は、$Increment の代わりに $Sequence を使用することを検討してください。 当然ですが、$Increment をすべて $Sequence に入れ替えてしまう前に、まずはドキュメンテーション を参照してください。
また、このテストの内容をそのまま鵜呑みにはせず、ご自身の目で確かめてください。
Caché 2015.2 からは、$Increment の代わりに $Sequence を使ってテーブルを操作できるようになりました。 そのために $system.Sequence.SetDDLUseSequence というシステム関数が用意されています。Management Portal の SQL 設定からも同じオプションを使用できます。
また、クラス定義には -- IDFunction という新しいストレージパラメーターがあります。「increment」にデフォルト設定されており、ID 生成に $Increment が使用されることを意味します。 (Inspector > Storage > Default > IDFunction と移動して)「sequence」に変更することができます。
おまけ
自分の notebook で他にも簡単なテストを行いました。DB サーバーをホストオペレーションシステムに、そしてアプリケーションサーバーを同じ notebook のゲスト VM にインストールした小さな ECP 構成を使っています。 ^Person はリモートのデータベースにマッピングしています。 いたってベーシックなテストなので、それを基に一般論を出すのは控えておきます。 $Increment と ECP を使用する際には 考慮すべき点 がいくつかあります。 では、結果をご覧ください。
$Increment を使った場合
USER>do ##class(DC.IncSeq.Test).run()
done:163.781288
^%SYS.MONLBL:
; ** メソッド 'filling' のソースコード **
1 10 .000503 -- lock +^P:"S"
2 10 .000016 set job = $job
3 10 .000044 for i=1:1:200000 {
4 1843745 1546.57015 set Id = $Increment(^Person)
5 1880231 6.818051 set surname = ##class(%PopulateUtils).LastName()
6 1944594 3.520858 set name = ##class(%PopulateUtils).FirstName()
7 1816896 16.576452 set ^Person(Id) = $ListBuild(job, surname, name)
8 1933736 .895912 }
9 10 .000279 lock -^P:"S"
; ** メソッド 'filling' のソースコード終わり **
;
; ** メソッド 'run' のソースコード **
1 1 .000045 kill ^Person
2 1 .000001 set z1 = $zhorolog
3 1 .000007 for i=1:1:10 {
4 10 .059868 job ..filling()
5 0 0 }
6 1 170.342459 lock ^P
7 1 .000005 set z2 = $zhorolog - z1
8 1 .000013 lock
9 1 .000018 write "done:",z2,!
; ** メソッド 'run' のソースコード終わり **
$Sequence を使った場合
USER>do ##class(DC.IncSeq.Test).run()
done:13.826716
^%SYS.MONLBL
; ** メソッド 'filling' のソースコード **
1 10 .000434 lock +^P:"S"
2 10 .000014 set job = $job
3 10 .000033 for i=1:1:200000 {
4 1838247 98.491738 set Id = $Sequence(^Person)
5 1712000 3.979588 set surname = ##class(%PopulateUtils).LastName()
6 1809643 3.522974 set name = ##class(%PopulateUtils).FirstName()
7 1787612 16.157567 set ^Person(Id) = $ListBuild(job, surname, name)
8 1862728 .825769 }
9 10 .000255 lock -^P:"S"
; ** メソッド 'filling' のソースコード終わり **
;
; ** メソッド 'run' のソースコード **
1 1 .000046 kill ^Person
2 1 .000002 set z1 = $zhorolog
3 1 .000004 for i=1:1:10 {
4 10 .037271 job ..filling()
5 0 0 }
6 1 14.620781 lock ^P
7 1 .000005 set z2 = $zhorolog - z1
8 1 .000013 lock
9 1 .000016 write "done:",z2,!
; ** メソッド 'run' のソースコード終わり **