概要
最近、Sleep
関数について、誤解をしていると思われる記述を多く見かけるようになりました。本記事では、間違った使用方法を例に挙げ、何が間違っているのか、どう使うべきなのかについて、考察します。
はじめに
いくつかの掲示板で、Sleep
関数の使い方を誤解しているのではないかと思われる質問を目にしました。その時は、「解説者はわかっているからいいか」と思っていたのですが、解説記事において間違った使い方をしている記事を目にしました。その為、誤った使い方の実例を元に、何が、何故、間違っているのか解説を行います。
本記事の実行環境について
本記事では、論理CPU数が2個の環境を想定しています。本記事で提示するコードを論理CPU数が3個以上の環境で実行する場合、スレッド数を増やして実行してください。
本記事で使用するコードは、Visual C++ 2008 にて作成しています。OpenMP をサポートしているなら、その他の環境でも、適切に修正することで十分に動作すると思われます。
Sleep関数について
まず、Sleep
関数のリファレンスを参照します。
指定された時間にわたって、現在のスレッドの実行を中断します。
中断時間として0msを指定してこの関数を呼び出すと、現在のスレッドは自らに割り当てられているタイムスライスの残りの部分を放棄します。
ここで注意していただきたいのは、「現在のスレッドの実行を中断します。」という箇所です。まず、「現在のスレッド」とは、何でしょうか。Sleep
関数を呼び出したスレッドのことです。それを「中断する」とは、どういうことでしょうか。それを知るためには、次の言葉にも注目してください。中断時間として0ミリ秒を指定すると、「現在のスレッドは自らに割り当てられているタイムスライスの残りの部分を放棄します」とあります。「現在のスレッド」がSleep
関数を呼び出したスレッドであることは同じです。そのスレッドに割り当てられた、「タイムスライスの残りの部分」とは、何でしょう。
CPUの動作を理解する
実は、Sleep
関数は、ただの関数ではなく、カーネルに実体が定義されている、システム関数です。Sleep
関数の動作を理解するには、CPUの動作について、若干の知識が必要です。Sleep
関数の理解を進めるために、まずCPUの動作について、理解をしましょう。
タスクマネージャーを起動してください。すると、たくさんのプロセスが実行されていることがわかります。これらのプロセスは、“同時に”実行されているわけですが、どうしてこのような事が出来るのでしょうか。
プロセスは、“同時に”実行されているわけではないのです。これらは、どうやって実行されているのでしょうか。
私たちも、ある期間に、複数の仕事を“同時に”実行することがあります。例えば私は、会社員であると同時に父親です。「1日」という時間の中で、会社員としての仕事と、父親としての仕事を行います。とはいえ、会社で父親業をするわけではありません。「1日」という時間を「1時間」等のもっと短い時間にわけ、ある時間には「会社員」、ある時間には「父親」として働いています。「1日」などの長い期間で見れば複数の業務を“同時に”行っていますが、瞬間瞬間を見ると、1つの業務だけを行っています。
CPUが複数のプロセスを実行するのも、これと同じ考え方です。各プロセスで実行されているスレッドを、CPUが処理する時間は決められています。この時間が経過すると、スレッドの実行は中断され、他のスレッドの実行が開始されます。中断されたスレッドは、実行待ち行列に戻されます。実行待ち行列の制御は、OSが行います。
Sleep関数の動作を理解する
CPUが、ごく短い時間に区切ってスレッドを実行しているということがわかったので、ふたたびSleep
関数に戻ります。
Sleep
関数は、「実行状態」に戻る時間を指定して、スレッドを「休止状態」にし、割り当てられた実行時間の残りの部分を放棄します。次に割り当て時間がきたとき、カーネルは、スレッドの状態が「休止状態」なので、実行状態に戻る時間を調べます。戻る時間を過ぎていれば「実行状態」に戻し、CPU時間を割り当てます。過ぎていなければ、待ち行列に戻します。
Sleep
関数の実際の動作は、「待つ」とか、「時間を経過させる」とかいうものではなく、CPUに実行してもらうために待っている行列へ、スレッドを登録する処理を一定時間阻害する、ということなのです。登録を阻害されることでCPUはそのスレッドを実行しなくなります。実行されないのでスレッドは「中断される」わけです。登録を阻害されるのはSleep
関数を呼び出したスレッドだけなので、他のスレッドはそれまでと同じように処理を継続します。
Sleep関数の間違った使用例
ここまで見てきたように、Sleep
関数は、何らかの処理を行うものではありません。したがって、「時間が経過することが重要なこと」の代わりに使うことは出来ますが、「何らかの処理」の代わりには、使えない場合があります。このふたつは、何が違うでしょうか。代わりに使用する事が出来るのは「時間を経過させるだけ」の場合です。後者のように、「達成できた処理」も計測しなければならない場合、代わりとして使用する事は出来ません。繰り返しますが、Sleep
関数は「何もしない」関数ですので、「達成できた処理」がありません。したがって、計測の主体が「処理」にある場合や、「高負荷な処理」の代わりとして使えないのです。
では、間違った使用例を紹介します。インドリさんによる、『OpenMPの実行時ライブラリと並列ループ』の4ページ目、「並列処理のパフォーマンスを測定するプログラム」です。
この記事のこの箇所では、「1ループあたりにかかる処理時間と処理回数を増加させつつ処理時間を計測」することで、「1ループあたりの処理時間と処理回数を増加させるにつれパフォーマンスが向上している」としています。そして、「1ループあたりの処理時間」として、Sleep
関数を使用しています。上で説明したように、「パフォーマンス」を計測するためにSleep
関数を使うことは、間違っています。「パフォーマンス」を計測するためには、単位時間あたりに実行した処理の数を計測しなければなりません。Sleep
関数では、「時間」しか計れず、「処理数」を計れないからです。では、正しく計測できていないことを証明するコードを、List1に示します。
このコードは、2つの処理を、スレッド数を変えて行います。今回は、1、論理CPU数、論理CPU数の2倍+1で行います。処理doSomething
は、「INT_MAX回、1を足し込む」です。処理doSleep
は、「1秒のSleep
を10回行う」です。
処理doSleep
は、インドリさんが使用されている、パフォーマンス向上の証明方法です。この方法が間違っている事を調べるために、論理CPU数を超えるスレッドで実行することにします。実際に処理を行うのはスレッドではなくCPUですから、論理CPU数を超えるスレッドを実行しても、パフォーマンスの向上にはならないはずです。
List1.Sleepでは処理効率が計れないことを証明するコード
#include <stdio.h>
#include <omp.h>
#include <windows.h>
#include <sys/types.h>
#include <sys/timeb.h>
#define TmToSec(X) ((X).time + (X).millitm / 1000.0)
#define DiffTmSec(START, STOP) (TmToSec(STOP) - TmToSec(START))
// 引数で渡されたスレッド数で処理し、経過時間を返す。
double doSomething(int n)
{
struct _timeb s, e;
int sum = 0;
int limit = INT_MAX;
_ftime_s(&s);
#pragma omp parallel for num_threads(n), reduction(+ : sum)
for (int idx = 0; idx < limit; ++idx) {
sum += 1;
}
_ftime_s(&e);
fprintf(stdout, "sum %d\t", sum);
return DiffTmSec(s, e);
}
double doSleep(int n)
{
struct _timeb s, e;
_ftime_s(&s);
#pragma omp parallel for num_threads(n)
for (int idx = 0; idx < 10; ++idx) {
Sleep(1000);
}
_ftime_s(&e);
return DiffTmSec(s, e);
}
int main(int argc, char* argv[])
{
int n = omp_get_num_procs();
int n2 = n * 2 + 1;
fprintf(stdout, "doSomething(%d) ... %.3f\n", 1, doSomething(1));
fprintf(stdout, "doSomething(%d) ... %.3f\n", n, doSomething(n));
fprintf(stdout, "doSomething(%d) ... %.3f\n", n2, doSomething(n2));
fprintf(stdout, "doSleep(%d) ... %.3f\n", 1, doSleep(1));
fprintf(stdout, "doSleep(%d) ... %.3f\n", n, doSleep(n));
fprintf(stdout, "doSleep(%d) ... %.3f\n", n2, doSleep(n2));
return 0;
}
図1.List1の実行結果一例
Sleep関数の間違った使用例の解説
では、実行結果を見ます。この実行結果は、論理CPU数が2の環境で実行しています。その為、スレッド数を1,2,5と変化させています。doSomething
関数では、スレッド数が1の場合、5.370秒かかっていました。スレッド数を2とした場合、半分の2.685秒程度になることが期待され、ほぼ期待通りの2.826秒が得られました。スレッド数が5の場合も、ほぼ期待通りの2.922秒となっています。doSleep
関数では、期待と異なる結果が得られました。スレッド数が1の場合、10秒かかります。スレッド数が2の場合、期待通りの5.003秒ですが、スレッド数が5の場合には、スレッド数が1の場合の10秒をスレッド数で割ったのと同じ、2.000秒となっています。
Sleep
関数が、スレッドに処理をさせない命令であることが原因です。
Sleep
関数の場合、この関数はスレッドに処理をさせないことが仕事なので、すぐに次のスレッドを処理し始めます。他のスレッドが実行するのもSleep
関数なので、Sleep
関数を5つ、並列に実行することが出来ます。つまり、いくらスレッドを起動しても、どのスレッドもCPUを使わないので、ただ時間が経過するだけです。したがって、全てのスレッドが何もすることなく、1回のループが約1秒後に終了します。1スレッドあたりのfor
文の実行回数が減るので、その分だけいくらでも「速く」なります。
しかし、何らかの処理をさせる場合は、どうでしょうか。「CPUの動作を理解する」の段で述べたとおり、CPUが処理できるのは、瞬間瞬間では1つのスレッドだけです。1つのスレッドがループする回数は減っていますが、1つのCPUが処理するループの回数は変わらないため、何らかの処理をする方は、スレッド数を増やしても処理時間は変わらないのです。そればかりか、実行しているスレッドを切り替えるために多少の時間がかかりますから、論理CPU数に対してスレッドを増やしすぎると、処理の効率が悪くなることが予想されます。
この様に、処理の効率が向上していることを説明するために、処理を行わないSleep
関数を使うことは、実際の処理と大幅に異なる結果をもたらします。また、CPUに負荷をかけませんから、「高負荷処理」の代わりとして使うことも出来ません。
なお、例に挙げた記事のコラムでは、「CPUキャッシュによって実行速度が上がっている」と考察されています。これについても言及しておきます。まず、考察の元になった計測の方法が間違っているので、意味がありません。記事ではスレッド数を2にしかしていないので、2倍近い値しか出ませんでした。今回の結果のように3にすれば3倍近い値、100にすれば100倍近い値が取れますので、これをCPUキャッシュによる効率化というのは、無理があります。また、日本語のMSDNには書かれていませんが、英語の方には次のように書かれています。
If dwMilliseconds
is less than the resolution of the system clock, the thread may sleep for less than the specified length of time. If dwMilliseconds
is greater than one tick but less than two, the wait can be anywhere between one and two ticks, and so on.
コメント欄で明らかにされているように、記事ではシステムクロックの分解能が15ミリ秒となっていました。したがって、Sleep
関数からの復帰には、指定した時間を挟んで15ミリ秒の幅があります。つまり、中断している時間が、指定時間よりも短くなることがあります。そのようなケースが都合良く蓄積すれば、実行時間はスレッド数倍以上に短くなります。
Sleep
関数が「処理」をしないことを確認するには
本文では、Sleep
関数が、スレッドを実行待ち行列に登録しないので負荷がかからないと、解説しました。しかし、コードは隠蔽されており、コードからそのことを確認する事は出来ません。また、インドリさんは、次のように主張されています。
中の人の徒然草349(2010-05-06(13:18) : インドリ)より:
私は負荷が高く処理効率値も高くなるからSleepを利用しました。
彼らはSleepで200ミリ秒になるという間違った前提を述べ、それが過ちだったので急いで大勢に見せかけて誤魔化しているようです。
何度も言っていますが、一行のプログラムでOSに負荷をかけ処理効率値が高くなるようにしたのです。
彼らは誤魔化して、Sleepは処理していないと言い訳をしていますが、Sleepはカーネルコードを動かしていますし、私はそもそもその様な事を言っておりません。
さて、Sleep
関数は負荷がかかるかどうか、どうにかして確認できないものでしょうか。
次のコードで確認できます。
List2.Sleepが処理をしないことを確認するコード(1/2)
#include <windows.h>
int main(int argc, char* argv[])
{
while (1) ;
}
図2.List2の実行結果一例
List3.Sleepが処理をしないことを確認するコード(2/2)
#include <windows.h>
int main(int argc, char* argv[])
{
while (1) Sleep(1);
}
図3.List3の実行結果一例
List2は、単純な無限ループです。これを実行し、タスクマネージャを起動します。すると、(いずれかの論理)CPUの使用率がほぼ100%になります。無限ループですので、強制終了してください。
次に、List3を実行します。これは、ループの間にSleep
関数を挟んだものです。こちらを実行すると、CPU使用率はほぼ0%になります。
論理CPU2つの環境で、CPUを1つ使って実行しているため、全体的なCPU使用率が半分の50%になっています。List3では、中断を挟んでいるため、CPUをほとんど使用していません。これらの結果から、Sleep
関数がCPUを使用しない、負荷がない処理であることがおわかりいただけるのではないでしょうか。
最後に
本記事は、実はブログ用に書いた物でした。しかし、「Sleep
関数は何も処理しない」と言うのは当たり前だと思い、掲載する前に削除してしまいました。ところが、CodeZineにてSleep
関数を処理効率の計測に使用している記事を見て、やはりこういう間違いをする人がいるのだと思い、間違った記事の載っているCodeZineにて発表することとしました。
何度も訂正をするように促されているのは、コメントを見ていただければわかります。この記事と同じことが、コメントで指摘されています。それでもまだ、「わかっていないのはあなた方だ」と言われるため、同じ記事として登録し、何故間違いなのかを解説することとしました。
この記事に技術的な間違いがあれば、どうぞ、忌憚無く、ご指摘ください。ただし、その場合、具体的な間違っている箇所、および間違っていることを確認できる方法も、同時にご提示ください。
参考資料
5/10 |
- UNIX 系の説明は削除。関数の内部へ潜るのも削除。その他、記事全体をシェイプアップ。
- コードをシェイプアップ。num_threads に変数を指定していいんだ!!atomic から reduction に変更(「リダクション処理」が、何のことかわからなかったの)。
- 「OpenMPのforディレクティブについて」は、長くするための付け足しだったので削除。
- コードの作成意図を追加。
- 「正しい」の基準を「スレッド数だけ速くなる」から「CPU数を超えるスレッドは無駄」に変更したので、解説の前半も修正。
- 参考資料追加。
|
5/28 |
- コラム「Sleep関数が「処理」をしないことを確認するには」を追加。
|