2015年1月27日火曜日

LINQメソッドのストリーミングと非ストリーミング

この前私の無知ゆえにTwitterで議論を起こしてしまったのでここにまとめておきます。

LINQの基本的な思想として遅延評価というものがあります。

1
2
3
4
5
Console.WriteLine("Start");
var e = Enumerable.Range(0, 10).Select(p => { Console.WriteLine(p); return p; });
Console.WriteLine("foreach Starting");
foreach(int i in e)
    Console.WriteLine("foreach " + i);

例えばこんなプログラムを実行したらどのような結果が出力されるでしょう。
Start
foreach Starting
0
foreach 0
1
foreach 1
2
foreach 2
3
foreach 3
4
foreach 4
5
foreach 5
6
foreach 6
7
foreach 7
8
foreach 8
9
foreach 9

こんな出力がされます。Select()メソッドが呼ばれたときではなく、値が必要になったときに値が列挙されています。これが遅延評価です。
では、次のようなコードの場合どうなるでしょう。Select()の後ろにReverse()が付きました。

1
2
3
4
5
Console.WriteLine("Start");
var e = Enumerable.Range(0, 10).Select(p => { Console.WriteLine(p); return p; }).Reverse();
Console.WriteLine("foreach Starting");
foreach(int i in e)
    Console.WriteLine("foreach " + i);

実行結果はこんな感じになります。
Start
foreach Starting
0
1
2
3
4
5
6
7
8
9
foreach 9
foreach 8
foreach 7
foreach 6
foreach 5
foreach 4
foreach 3
foreach 2
foreach 1
foreach 0

先ほどと違って、foreachで各値が列挙されるごとに1回ずつSelect()に与えたデリゲートが呼ばれるのではなく、foreachが始まるときに全ての値が評価されたうえで逆順に列挙されています。
というのも割と当然で、IEnumerator<T>は現在の値を示すCurrentプロパティと次の値へ移動するMoveNext()メソッドしか持っていません(まあReset()とかDispose()とかありますがここでは関係ないので置いておきます)。すなわち、コレクションを逆順にたどることができないので、Reverse()が返すIEnumerable<T>のオブジェクトは、いったん全ての値を評価して配列とかにストックしておき、それを逆順に返すことでその機能を実現しています。

私は、これを遅延評価とは言わないと思ってしまっていました。
最初の例のように、その値を使用しようとしたときに初めて評価するような機構が遅延評価であり、そうすることで分散してデータベースにアクセスすることができるメリットが享受できるところまで含めて遅延評価の定義だと認識していました。そのため、いったん全ての値を評価しないと最初の値を提供できないReverse()メソッドは遅延評価に当たらないと思っていました。

しかし、上記のWikipediaを見ていただくとわかるように、遅延評価そのものの定義には分散といったような言葉は出てきません。遅延評価では分散して実行できることは保証されておらず、都合が良いデータならもしかしたら分散して処理できるかもしれないくらいのもののようです。いわば、分散して処理ができるのは遅延評価の効果でしかなく、あくまでも「必要になった時に必要最低限の評価をする」が遅延評価です。

後者の場合もforeachが始まるまで値は評価されていません。なので遅延評価です。ただし、始まった瞬間に全ての値を評価しないと逆転させた時の最初の値(=元のコレクションの最後の値)がわからないので、必要最小限の評価として全ての値を評価しているというだけのことです。

私のこのもともとの発想は実は遅延評価とは若干違ったところで存在していたようです。

実行方法による標準クエリ演算子の分類

LINQには「即時評価」と「遅延評価」の2種類のメソッドがあり、その遅延評価のメソッドはさらに「ストリーミング」と「非ストリーミング」に分類できるようです。私がもともと遅延評価の定義だと勘違いしていた「実行時に分散的に評価を行う」メソッドがここでいうストリーミングに分類されます。逆に遅延評価ではあるけど、評価開始時にすべての値を評価してしまうのが「非ストリーミング」に分類されています。
例えばSelect()はストリーミングですが、Reverse()は非ストリーミングになっています。なるほど納得です。
ちなみに、ストリーミングと非ストリーミングの両方にチェックが付いているものがあります。例えばExcept()なんかがそれです。2つ以上のコレクションを扱うメソッドのときは両方の特性が付く場合があるようで、Except()の場合は例えばsecondで与えたコレクションを全て評価しないとその値を持つかどうかがわからないので、そういった仕様になっているということです。firstの値そのものはストリーミングで実行されます。


これでまた一つ賢くなりました。
今後LINQを使うときは単に「遅延評価」だけでなく、この辺も意識しながら使いたいですね。

2015年1月24日土曜日

WPF用縦書きテキストブロック Tategaki ver.2.1.0

さて、前回はコントロールの更新だったわけですが、今回はロジックの更新になります。

とは言っても、自分はあまりフォント周りに詳しくないうえ、この辺のことは昔からWindowsシステムから上位のAPIが提供されていたこともあり、あまり情報は転がっていません。しかし、かねてから、Uniscribeに頼らず何かしらの方法でフォントファイルを直接読み出してグリフ変換等ができればいいなあと思っておりました。
今までのUniscribeを使ったTategakiTextを使っていただくとわかると思うのですが、MSゴシックやMS明朝、メイリオと言ったような代表的なフォントは何とかちゃんと動くのですが、それ以外のフォントを使うと動かないものもしばしばあり、もっと言えば、TrueType Collectionのフォントファイル(拡張子が.ttcのやつ)の扱い方が分からなかったのでMS Pゴシックなどが使えないという問題もありました。さらに、不定期で発動する、なぜかグリフインデックスを縦書き用に変換できない問題など、非常に悩ましい現象がしばしばありました。

しかし、昨日ネットを巡回していると、非常に興味深いライブラリを見つけました。

TypeLoader

もはやなんで気づかなかったんだというレベルの話ですが、TrueTypeまたはOpenTypeのフォントファイルを直接パースしてグリフインデックスを変換してくれるライブラリになります。まさに求めていたものです。
と、同時に、このトップページを見てもらってもわかりますが、結構このライブラリもWPFを視野に入れたライブラリになっているようなので、こっちに立派なWPF縦書きコントロール機能があったら、このプロジェクト自体完全にリサーチ不足による車輪の再発明だったことになっちゃうじゃんという不安もよぎりました。実際、デモプログラムも付いていましたが、コードを読んでみるとMainWindowのコードビハインドにガッツリグリフインデックス変換まわりのコードが書いてあって、私のこのライブラリのコントロールとしての立ち位置も保てそうで一安心でした。
 

さて、こうと来たらグリフインデックスの変換周りをUniscribeからTypeLoaderに置き換えましょう。
TypeLoaderにはTrueType Collectionフォントを扱う機能もあるようなので、ついでに、使用可能フォントのサーチも強化して、より多くのフォントに対応していくことにしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/// <summary>
/// 有効なフォントの一覧を取得するメソッド
/// </summary>
/// <param name="cultures">フォントのカルチャの配列</param>
/// <returns>ファミリ名とstringのDictionary</returns>
internal static IDictionary<string, Uri> SearchFontNamePathPair(IEnumerable<CultureInfo> cultures)
{
    IDictionary<string, Uri> dic = new SortedDictionary<string, Uri>();
    string FontDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
 
    var uris = Directory.GetFiles(FontDir, "*.ttf").Concat(Directory.GetFiles(FontDir, "*.otf")).Select(p => new Uri(p))
        .Concat(Directory.GetFiles(FontDir, "*.ttc").SelectMany(p => {
            using(var fs = new FileStream(p, FileMode.Open, FileAccess.Read)) {
                return Enumerable.Range(0, TypefaceInfo.GetCollectionCount(fs)).Select(i => new UriBuilder("file", "", -1, p, "#" + i).Uri);
            }
        })
    );
 
    foreach(Uri uri in uris) {
        try {
            GlyphTypeface gtf = new GlyphTypeface(uri);
            if(cultures.Where(p => gtf.FamilyNames.ContainsKey(p)).Count() > 0) {
                foreach(string FamilyName in gtf.FamilyNames.Values) {
                    if(!dic.ContainsKey(FamilyName))
                        dic.Add(FamilyName, uri);
                }
            }
        }
        catch(NullReferenceException) { }
    }
 
    return dic;
}

まずはフォントファミリ名とフォントファイルのUriを対応させるお仕事をします。システムディレクトリのフォントフォルダのパスを取得し、そこから.ttf、.otf、.ttcの3つの拡張子のファイルをすべて拾います。.ttcに関してはTypeLoaderのTypefaceInfo.GetCollectionCount()メソッドを使っていくつフォントが格納されているかを調べ、それぞれを登録していきます。
次に、UriをもとにGlyphTypefaceのインスタンスを作成し、ファミリ名を取得し登録していきます。それだけです。

1
2
3
4
5
GlyphTypeface gtf = new GlyphTypeface(FontUri);
TypefaceInfo ti = new TypefaceInfo(GlyphTypeface.GetFontStream(), string.IsNullOrEmpty(FontUri.Fragment) ? 0 : int.Parse(FontUri.Fragment.Replace("#", "")));
SingleGlyphConverter vert = ti.GetVerticalGlyphConverter();
 
ushort[] indices = Text.Select(p => vert.Convert(gtf.CharacterToGlyphMap[gtf.CharacterToGlyphMap.ContainsKey(p) ? p : '?'])).ToArray();

変換はこんな感じになります。GlyphTypefaceのインスタンスからTypefaceInfoとSingleGlyphConverterのクラスを作れば、あとはSingleGlyphConverter.Convert()を通して縦書き用グリフインデックスに変換することができます。

ですが、 TategakiMultilineは1文字ずつTategakiTextインスタンスを作っているので、TategakiMultiline.Text更新時はかなりの回数この変換ルーチンが呼ばれることになります。毎回フォントファイルをパースしていてはコストが大きいので1度生成したインスタンスは保存し、グリフインデックスはキャッシュするようにしています。まあでもそのキャッシュ機構を説明したところでわかりにくくなるだけでしょうから、興味ある人はソースコードでも眺めてください。


気が付いたらあっという間にver.1系のコードが跡形もなくなってしまいましたね。


ダウンロードはこちら
WPF用縦書きテキストブロック Tategaki ver.2.1.0

2015年1月21日水曜日

WPF用縦書きテキストブロック Tategaki ver.2.0.0

なんてういか、公開時に迂闊ににバージョンを1にしてしまったせいで、今回早速バージョン2になってしまいました。

今回は、Uniscribeのロジック以外ほぼゼロベースで作りなおしています。そのため、一部互換性が無かったり仕様が変更されていたりするところがあるので注意してください。


今までのTategakiTextとTategakiMultilineはUserControlクラスを使って作っておりました。UserControlだとUIデザイナーの支援が受けられるなどのメリットはあるのですが、まあ一番の理由は自分が詳しい知識がなかったというところですかね。

代わりに、Controlクラスを継承した、「カスタムコントロール」としてTategakiTextとTategakiMultilineを実装しました。 そのため、FontFamilyやFontSize等のプロパティを設定しなければそのまま親の値が引き継がれるなど、コントロールとして非常に自然な振る舞いをできるようになりました。

また、グリフインデックスのキャッシング機能を付けて、Uniscribe周りの関数の呼び出しを最小に抑え、高速化しました。これはとりあえず実装したものの、あまり高速化には効果が無かったかもしれませんね…。
他にも、今までは、Glyphsクラスは空文字列を渡すと例外を吐くため、TategakiTextではTextプロパティが空になるとダミーで半 角スペースをGlyphsクラスに渡すようにしていました。そのため、文字が無いはずなのにTategakiTextは半角スペース分の大きさを持ったコ ントロールになってしまうという現象がありました。そこで、Textが空文字列になったら、そのままVisibilityプロパティをVisibility.Collapsedにしてコントロールそのものを消滅させるようにしました。これによって、TextBlockにより近い挙動ができるようになりました。

一方、TategakiMultilineは、今までは1行の表示量を計算して横方向のStackPanelに行ごとのTategakiTextを積み重ねていくような設計にしていましたが、今回は1文字ずつTategakiTextにしてWrapPanelに入れて自動的に折り返させるようにしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<ItemsControl x:Name="PART_ItemsControl" RenderTransformOrigin="0.5,0.5" >
    <ItemsControl.Resources>
        <vc:LineMarginConverter x:Key="MarginConverter" />
    </ItemsControl.Resources>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <ItemsControl ItemsSource="{Binding}">
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <local:TategakiText Text="{Binding}" RenderTransformOrigin="0.5,0.5"
                                            Spacing="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:TategakiMultiline}}, Path=Spacing}"
                                            Margin="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:TategakiMultiline}}, Path=LineMargin, Converter={StaticResource MarginConverter}}"
                                            x:Name="tategaki">
                            <local:TategakiText.RenderTransform>
                                <ScaleTransform ScaleX="-1" />
                            </local:TategakiText.RenderTransform>
                        </local:TategakiText>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <WrapPanel Orientation="Vertical" />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
            </ItemsControl>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.RenderTransform>
        <ScaleTransform ScaleX="-1" />
    </ItemsControl.RenderTransform>
</ItemsControl>                       

このように、ItemsControlの中にItemsControlを入れたような構造になっていて、外側は水平方向StackPanel、内側は縦方向のWrapPanelになっています。段落ごとにWrapPanelを生成し、それをStackPanelに入れてるという形です。文字は基本的に1文字ずつTategakiTextのインスタンスにしますが、禁則処理によって改行が入っちゃいけないところはその前後の文字をつなげて1つのTategakiTextにすることで、WrapPanelに入れるだけで改行しては行けないところの前で改行が入るようにしています。
ちなみに、当初から使っているやり方ですが、StackPanelもWrapPanelも左から右へ積み重ねられる(日本語の縦書きとは逆)ので、表示テ キストを左右反転させてからWrapPanelやStackPanelに入れて、そのパネルを再び左右反転させるという手法で右から左へ積み重ねているよ うに見せています。 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void SetText()
{
    if(itemsctl != null) {
        if(!string.IsNullOrEmpty(Text))
            Uniscribe.GetGlyphs(Text, FontFamily.Source, FontSize);    //1回呼び出しておくとキャッシュされる
 
        IEnumerable<string> splited = (Text ?? string.Empty).Split('\n').Select(p=>string.IsNullOrEmpty(p) ? " " : p);
 
        itemsctl.ItemsSource = splited.Select(p => {
            if(p.Length == 0)
                return new string[] { };
            else if(p.Length == 1)
                return new string[] { p.First().ToString() };
            else {
                List<string> ret = new List<string>(p.ToCharArray().Select(p1 => p1.ToString()));
 
                for(int i = 0; i < ret.Count; i++) {
                    if(i > 0 && HeadForbiddenChars.Contains(ret[i].First())) {
                        ret[i - 1] = ret[i - 1] + ret[i];
                        ret.RemoveAt(i);
                        i -= 2;
                        continue;
                    }
                    if(i < ret.Count - 1 && LastForbiddenChars.Contains(ret[i].Last())) {
                        ret[i] = ret[i] + ret[i + 1];
                        ret.RemoveAt(i + 1);
                        i -= 1;
                        continue;
                    }
                }
 
                return ret.AsReadOnly().AsEnumerable();
            }
        }).ToArray();
    }
}

さきほどのXAMLに対応するメソッドはこんな感じになっていて、行ごとに区切った文字列を、さらに改行可能な文字単位に分割している様子がわかるかと思います。これで、文字単位でWrapPanelに入れることで実現するTategakiMultilineが作れました。

今回のTategakiMultilineではこのような実装をしたため、リサイズ時の負荷は、リサイズされるごとにTategakiTextのインスタンスを生成していた以前のものに比べて非常に軽くなっていて、特に縦方向の表示領域が狭いときにその速さが顕著に現れます。しかし、Spacingと文字のぶら下げが実装できなくなったのと、1文字ごとにTategakiTextインスタンスを生成しているので(しかもItemsControlをXAML+C#でWPFらしい書き方をしたので、すでに存在するインスタンスの再利用すらできていない)、表示テキスト変更時の負荷が信じられないほど大きくなってしまうという問題もまた新たに生まれました。しかし、例えば将来的に「半角文字だけ90°回転の横書きで表現する」とか、そういった拡張を考えた場合WrapPanelのほうが幾分か実装が楽そうな気もしますよね。
まあ、結局今までの実装と今回の実装のどちらがいいかって考えると、それは一長一短だということになるので、従来の手法のTategakiMultilineも実装しようかと思いました。が、まあ、個人的なモチベーションの都合上、それは延期することにしました。必要になったらやるかもしれません。


機能の対応表はこんな感じですかね。まあTategakiTextだけ使うなら更新しておいて損は無いかなと思います。


Spacingが削除されたので調整用スライダーを無効状態にしていますが、コントロールが作り直しにもかかわらずこのように以前のTategakiText等と同じように使えていることがわかるかと思います。

ダウンロードはこちら
WPF用縦書きテキストブロック Tategaki ver.2.0.0

2015年1月16日金曜日

WPFのScrollViewerやScrollBarのスクロール位置を同期させる

突然ですが、複数のScrollViewerでスクロール位置を同期させたいことってありませんか?

画面に入りきらないような大きな領域を画面に表示するのにScrollViewerはとても便利です。そして、それは自動でスクロールバーの表示のON/OFFからスクロール制御までやってくれるので、何も考えずにスクロールしたいコンテンツをScrollViewerの中に突っ込めばたいていの場合は片付きます。

そこで、複数のScrollViewerでスクロール位置を同期させたいことがしばしば出てきますよね。
例えば、2つの画像の比較ソフトを作るとき、2つの画像は同時にスクロールさせたいですよね。個別にそれぞれスクロールさせたんじゃ使い勝手が悪すぎます。
そのほかにも、例えばExcelの「ウィンドウ枠を固定」機能みたいな感じで、何かしらの表を表示させるとき、タイトル領域とそのテーブル領域でそれぞれ縦方向のみスクロールを同期したり、横方向のみスクロールを同期させたいことなんかがあると思います(ListViewとかを使えよっていうツッコミはとりあえず置いておいて)。

さて、この手の機能の実現をしようと思ったとき、WPFerならXAMLでScrollPosみたいなプロパティを2つのScrollViewer間でバインディングしてしまえばいいだろうっていう発想になるかと思います。
そもそもスクロール位置なんてUI上の問題のはずですから、ビジネスロジック等から分離してXAML内で済ませてしまいたいですよね。
そこでいろいろ調べてみるとScrollBarには確かにValueプロパティが存在してこれらをバインディングすることで複数のScrollBar間でのスクロール位置の同期はできますが、ScrollViewerにはそういったプロパティは無いんですね…。いや、正確にはContentVerticalOffsetContentHorizontalOffsetといったスクロール位置を取得できるプロパティはあります。しかし、これはセッターがprivateになっており、これを通してスクロール位置を更新することはできません。スクロール位置を変えるにはScrollToVerticalOffset()ScrollToHorizontalOffset()等のメソッドを呼び出さなければなりません。XAMLからメソッドを呼び出すのはとても難しいですね。

簡単な解決方法としては、コードビハインドでScrollViewerやScrollBarのイベントをキャッチして、その値を更新し同期させるということでしょう。しかし、XAMLからどのコントロール同士が同期しているのかが見通せなくなりますし、コードビハインドも、例えば4つのScrollViewerのうち2つずつが同期する場合などは本当にややこしいコードになってしまいそうです。こういうのは望ましくないですね。


そこでBehaviorがあるんですよ。


BehaviorはViewの振る舞いをC#等で記述できる仕組みです。コードビハインドほど特定のウィンドウやコントロールに密接にくっついているものではないので、ある程度の幅を持たせることができます。さらに、BehaviorはDependencyObjectを継承しているので依存プロパティを持たせられ、非常にXAMLとの親和性も高いです。

というわけで、まず、使う側から考えてみます。

1
2
3
4
5
6
<ScrollViewer HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden">
    <!-- 中略 -->
    <i:Interaction.Behaviors>
        <b:ScrollSyncronizingBehavior ScrollGroup="Group1" Orientation="Vertical" />
    </i:Interaction.Behaviors>
</ScrollViewer>

こんな風にScrollViewerにスクロールを同期させるBehaviorを設定してあげます。そして、そこにScrollGroupでグループ名を指定し、グループ名が同じScrollViewerやScrollBarは全部スクロールを同期するようにしています。Orientationはスクロールを同期させる向きです。縦横両方向とも同期したければ、このScrollSyncronizingBehaviorを縦と横2つ設定してやればいいでしょう。
こうして、複数個所のScrollViewerやScrollBarが同期させられれば便利ですね。

そのBehaviorの実装がこれになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
public class ScrollSyncronizingBehavior : Behavior<Control>
{
    static Dictionary<string, List<Control>> SyncGroups = new Dictionary<string, List<Control>>();
 
    protected override void OnAttached()
    {
        base.OnAttached();
 
        AddSyncGroup(ScrollGroup);
    }
 
    protected override void OnDetaching()
    {
        base.OnDetaching();
 
        RemoveSyncGroup(ScrollGroup);
    }
 
    /// <summary>
    /// スクロールグループ
    /// </summary>
    public string ScrollGroup
    {
        get { return (string)this.GetValue(ScrollGroupProperty); }
        set { this.SetValue(ScrollGroupProperty, value); }
    }
    private static readonly DependencyProperty ScrollGroupProperty = DependencyProperty.Register(
        "ScrollGroup", typeof(string), typeof(ScrollSyncronizingBehavior), new FrameworkPropertyMetadata((d, e) => {
            ScrollSyncronizingBehavior me = (ScrollSyncronizingBehavior)d;
 
            me.RemoveSyncGroup((string)e.OldValue);
            me.AddSyncGroup((string)e.NewValue);
        })
    );
 
    /// <summary>
    /// スクロールの向き
    /// </summary>
    public Orientation Orientation
    {
        get { return (Orientation)this.GetValue(OrientationProperty); }
        set { this.SetValue(OrientationProperty, value); }
    }
    private static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
        "Orientation", typeof(Orientation), typeof(ScrollSyncronizingBehavior), new FrameworkPropertyMetadata()
    );
 
    /// <summary>
    /// 同期グループに追加するメソッド
    /// </summary>
    /// <param name="GroupName">グループ名</param>
    /// <returns>成功したかどうか</returns>
    bool AddSyncGroup(string GroupName)
    {
        if(!string.IsNullOrEmpty(ScrollGroup) && (this.AssociatedObject is ScrollViewer || this.AssociatedObject is ScrollBar)) {
            if(!SyncGroups.ContainsKey(GroupName))
                SyncGroups.Add(GroupName, new List<Control>());
            SyncGroups[GroupName].Add(this.AssociatedObject);
 
            ScrollViewer sv = this.AssociatedObject as ScrollViewer;
            ScrollBar sb = this.AssociatedObject as ScrollBar;
 
            if(sv != null)
                sv.ScrollChanged += ScrollViewerScrolled;
            if(sb != null)
                sb.ValueChanged += ScrollBarScrolled;
             
            return true;
        } else
            return false;
    }
 
    /// <summary>
    /// 同期グループから削除するメソッド
    /// </summary>
    /// <param name="GroupName">グループ名</param>
    /// <returns>成功したかどうか</returns>
    bool RemoveSyncGroup(string GroupName)
    {
        if(!string.IsNullOrEmpty(ScrollGroup) && (this.AssociatedObject is ScrollViewer || this.AssociatedObject is ScrollBar)) {
            ScrollViewer sv = this.AssociatedObject as ScrollViewer;
            ScrollBar sb = this.AssociatedObject as ScrollBar;
             
            if(sv != null)
                sv.ScrollChanged -= ScrollViewerScrolled;
            if(sb != null)
                sb.ValueChanged -= ScrollBarScrolled;
 
            SyncGroups[GroupName].Remove(this.AssociatedObject);
            if(SyncGroups[GroupName].Count == 0)
                SyncGroups.Remove(GroupName);
             
            return true;
        } else
            return false;
    }
 
    /// <summary>
    /// ScrollViewerの場合の変更通知イベントハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void ScrollViewerScrolled(object sender, ScrollChangedEventArgs e)
    {
        UpdateScrollValue(sender, Orientation == Orientation.Horizontal ? e.HorizontalOffset : e.VerticalOffset);
    }
 
    /// <summary>
    /// ScrollBarの場合の変更通知イベントハンドラ
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    void ScrollBarScrolled(object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        UpdateScrollValue(sender, e.NewValue);
    }
     
    /// <summary>
    /// スクロール値を設定するメソッド
    /// </summary>
    /// <param name="sender">スクロール値を更新してきたコントロール</param>
    /// <param name="NewValue">新しいスクロール値</param>
    void UpdateScrollValue(object sender, double NewValue)
    {
        IEnumerable<Control> others = SyncGroups[ScrollGroup].Where(p => p != sender);
 
        foreach(ScrollBar sb in others.OfType<ScrollBar>().Where(p => p.Orientation == Orientation))
            sb.Value = NewValue;
        foreach(ScrollViewer sv in others.OfType<ScrollViewer>()) {
            if(Orientation == Orientation.Horizontal)
                sv.ScrollToHorizontalOffset(NewValue);
            else
                sv.ScrollToVerticalOffset(NewValue);
        }               
    }
}

BehaviorはBehavior<T>を継承しますが、このTはBehaviorを設定するクラスになります。普段は特定のクラスを入れることが多いですが、今回はScrollViewerとScrollBarの両方に対応させるために、その共通の親クラスであるControlクラスを指定しています。

OnAttached()とOnDetaching()は、このBehaviorが特定のコントロールにアタッチ/デタッチされたときに呼ばれるメソッドです。ここで登録作業をしてあげます。
一方で、依存関係プロパティのScrollGroupでも、グループ名が変更されたときにその対応をそちらでしています。

グループ名の管理はstaticなDictionaryでやっていて、グループ名をキーに同じグループのインスタンスをListで保持するようにしています。そして、ScrollViewerならScrollChanged、ScrollBarならValueChangedイベントをハンドルするようにして、そこで同じグループ内の全インスタンスに対してスクロールをするメソッドを呼び出しています。


ちなみにですが、ScrollBarコントロールはスクロールバーのつまみの大きさを制御してやる必要があります。画面に対してコンテンツの大きさが大きければつまみは小さくなりますよね。
これはBehaviorをわざわざ使わなくてもXAML内のバインディングで可能です。

1
2
3
<ScrollViewer Name="scrollViewer1" />
<ScrollBar Orientation="Vertical" Maximum="{Binding ElementName=scrollViewer1, Path=ScrollableHeight}" Minimum="0"
           ViewportSize="{Binding ElementName=scrollViewer1, Path=ActualHeight}" />

ScrollViewerに適当な名前を付けてあげて、それをScrollBarのほうで参照しています。使うのはMaximum、Minimum、ViewportSizeです。MaximumとMinimumはスクロールするコンテンツの最大座標と最小座標で、ViewportSizeはそれを表示する画面領域のサイズです。なので、例えば縦方向ならばScrollViewerの上記のようなプロパティに対応しています。まあ、あとはSmallChangeやLargeChangeの設定をしてあげれば良いでしょう。

このようにScrollViewerとScrollBarのスクロール値までプロパティで同期できれば申し分なかったのですが、それはできなかったのでこうやってBehaviorで実現してみました。
BehaviorでScrollViewerのスクロール位置をセットもゲットもできる依存プロパティを用意してバインディングすればいいんじゃないか?と思う人もいるかもしれませんが、なぜかうまくいきませんでした。 なぜでしょうね。僕のコードのどこかが悪かったんでしょうかね。

試しに2枚の画像のスクロールを同期させるソフトを書いてみました。


2枚の画像が並んでいます。どのスクロールバーでも動かせますし、ScrollViewer上でマウスホイールでスクロールしてもタッチパネルでスクロールしても全部連動します。

一応、このプロジェクトもうpしておきました。
参考にどうぞ。

ScrollSync.zip (1,184KB)

2015年1月8日木曜日

NTP時計のうるう秒対応

2015年7月1日(JST)にうるう秒が挿入されることが決まりました。

うるう秒実施日一覧

すなわち、JSTで1秒ごとに8:59:59→8:59:60→9:00:00と時間が進んでいくことになります。
これは、現在、国際的な標準時を決めるための原子時計と地球の自転速度のズレ(自転速度がそもそも一定ではないため)によって発生する時間差を調整するためのものです。これを導入しないと、だんだん朝、昼、夜と言った太陽の昇り沈みに関する感覚と時刻が対応しなくなってきてしまいます。
最近に導入されたのは2012年7月1日(JST) で、慣例で12/31と6/30の24時直前(UTC)すなわち、1/1と7/1の9時直前(JST)に挿入されることが多いようです。また、いまのところ1分が61秒になる追加パターンしか実施されたことがないないようです。

さて、このうるう秒ですが、対応している時計は8:59:60の表示がされるそうです。
しかし、例えばWindowsをはじめとして多くの時計は対応しておらず、うるう秒が過ぎた後の時刻合わせで正確な時刻に合わせられるというのが実情のようです。そもそもうるう年と違ってうるう秒は不定期に挿入されるものなので電波時計やNTP等の外部と通信しているものでなければ原理上うるう秒に対応させられませんし、ずれは1秒しかなく、日常においては些細な差でしかありません。そのため実装が進まないんでしょうね。
もちろん、今回のNTP時計もうるう秒に対応しておりません。うるう秒の後の同期で正常な時刻になるだけです(自動内蔵オシレーター調整機能があるのでそれが多少ずれると考えられますが、後に収束すると考えられます)。

ですが、せっかくのうるう秒なので、これを機に実装してみることにしました。

まず、うるう秒をどうやって知るかです。
NTP時計では、SNTPプロトコルでインターネットを介して時刻情報を仕入れているわけですが、実はこのSNTPプロトコルにはうるう秒を通知する仕組みがあります。

http://tools.ietf.org/html/rfc5905#page-20

Leap Indicator (LI)と呼ばれる2bitのフィールドがSNTPのパケットにあり、これを見ることでその日にうるう秒が挿入されているかどうかがわかります。00(2進数)のときにうるう秒無し、01(2進数)のときに1日の最後の1分が61秒になる、10(2進数)のときに1日の最後の1分が59秒になることを示しています。もちろんこれはUTCなので、JSTでは午前9時を境にこのフィールドが変わります。

ネットワ-クによる時刻情報提供サービス(NTPサ-ビス)のうるう秒対応

上記のリンクには、2005年末に挿入されたうるう秒に関するNTPのLIの動きとNTP時刻の動きが示されています。
2005/12/31 0:00:00(UTC)になると同時にLIが01になり、2006/1/1 0:00:00(UTC)にLIが00に戻っています。そして、2005/12/31 23:59:60と2006/1/1 0:00:00はUTCタイムが同じ値になっています。
ここには60秒から00秒への遷移の間の細かいことは書いていませんね。サーバーが使用しているプログラムによっては、NTP時刻が3345062400.99から3345062400.00に戻る『時間逆行』仕様になっているものもあるらしいですし、時間が逆行しないように時間の進みを遅めている仕様になっているものもあるらしいので、このあたりはNTPサーバーに問い合わせをしないほうが無難かと思います。もしも時間逆行仕様だったら、単にLIを23:59:60か0:00:00かの判定に使って秒未満の桁をそのまま採用することができますが、本来のNTPの仕様では逆行しないようにするように言われている(まあ物理に沿っていると言えばこっちのほうが正しいですよね)ようなので、このあたりは本当に微妙なところかと思います。


さて、それでは実装の話に移ってみましょう。

1
2
3
4
5
typedef enum _tagLeapIndicator {
    LI_NoWarning = 0,
    LI_Increase = 1,
    LI_Decrease = 2,
} LeapIndicator;

まずはなんといってもLeapIndicatorの定義です。NTPパケットのビットフィールドにそのまま連動して、このような列挙型を作ってあげました。
次に、受信パケットについてLIの処理を追加します。(今まではこのようになっていました)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
case SM_UDP_RECV:
// Look for a response time packet
if(!UDPIsGetReady(MySocket))
{
    if((TickGet()) - dwTimer > NTP_REPLY_TIMEOUT)
    {
        // Abort the request and wait until the next timeout period
        UDPClose(MySocket);
        //dwTimer = TickGetDiv64K();
        //SNTPState = SM_SHORT_WAIT;
        SNTPState = SM_HOME;
        MySocket = INVALID_UDP_SOCKET;
        break;
    }
    break;
}
 
// Get the response time packet
w = UDPGetArray((BYTE*) &pkt, sizeof(pkt));
UDPClose(MySocket);
dwTimer = TickGetDiv64K();
SNTPState = SM_WAIT;
MySocket = INVALID_UDP_SOCKET;
bForceSync = FALSE;
 
// Validate packet size
if(w != sizeof(pkt))
{
    break;   
}
 
// Set out local time to match the returned time
dwLastUpdateTick = TickGet();
dwSNTPSeconds = swapl(pkt.tx_ts_secs) - NTP_EPOCH;
// Do rounding.  If the partial seconds is > 0.5 then add 1 to the seconds count.
if(((BYTE*)&pkt.tx_ts_fraq)[0] & 0x80)
    dwSNTPSeconds++;
 
{
    QWORD now,  qwReceive, qwTx, qwDelay, qwPeriod;
     
    now = MillisecondToNTPTimestamp(SNTPGetUTCMilliseconds());
    qwPeriod = now - qwLastUpdateNTPTimestamp;
    if((liLastUpdate != LI_Increase) && (pkt.flags.leapIndicator != LI_NoWarning))
        qwPeriod += (QWORD)1 << 32;
    else if((liLastUpdate != LI_Decrease) && (pkt.flags.leapIndicator != LI_NoWarning))
        qwPeriod -= (QWORD)1 << 32;
 
    qwLastUpdateTick = TickGetQWord();
 
    qwReceive = PacketToNTPTimestamp(pkt.recv_ts_secs, pkt.recv_ts_fraq);
    qwTx = PacketToNTPTimestamp(pkt.tx_ts_secs, pkt.tx_ts_fraq);
    qwDelay = (now - qwLastSendNTPTimestamp - (qwTx - qwReceive)) / 2;
 
    qwLastUpdateNTPTimestamp = qwTx + qwDelay;
 
    if(qwPeriod > 0) {
        dOscillatorError = (double)((LONGLONG)(now - qwLastUpdateNTPTimestamp)) / qwPeriod;
        dwSyncCount++;
    }
}
 
Stratum = pkt.stratum;
liLastUpdate = pkt.flags.leapIndicator;
 
break;

主に改造したのは最後のブロック部分です。
まず、同期周期のqwPeriodですが、今まではうるう秒を何も考慮しておりませんでした。今回の修正では、以前の同期でLIが1秒増しで今はLI無しだった場合、以前の同期以降にうるう秒が挿入されたと言えるのでqwPeriodに1秒を足しています。同様に、LIが1秒減を示していたら1秒減らす処理をしています。
あとは、最後にliLastUpdateとして、pkt.flags.leapIndicatorを保存してあげています。

次は、LIを外から取得できるように、そういったメソッドを作ってあげます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
LeapIndicator GetLeapIndicator()
{
    QWORD lastupdate = SNTPGetLastUpdateUTCMilliseconds();
    QWORD delta = GetDeltaMs();
 
    switch(liLastUpdate) {
     case LI_NoWarning:
         return LI_NoWarning;
     case LI_Increase:
        if((lastupdate / 86400000) != ((lastupdate + delta - 1000) / 86400000))    //Now leap second has already passed.
            return LI_NoWarning;
        else
            return liLastUpdate;
     case LI_Decrease:
        if((lastupdate / 86400000) != ((lastupdate + delta + 1000) / 86400000))    //Now leap second has already passed.
            return LI_NoWarning;
        else
            return liLastUpdate;
    }
    return liLastUpdate;
}
 
BOOL IsNowLeapSecond()    //Now Leaping 23:59:60
{
    if(liLastUpdate == LI_Increase) {
        QWORD now = SNTPGetLastUpdateUTCMilliseconds() +  GetDeltaMs();
 
        if((now / 86400000) != ((now - 1000) / 86400000))    //Now leap second
            return TRUE;
    }
    return FALSE;
}

GetLeapIndicator()のほうですが、これは単にそのまま保存したLIの値を返してしまうと、直前のNTPサーバーとの同期は0:00:00(UTC)以前なのに今は0:00:00(UTC)過ぎという時刻のときに、その日は別にうるう秒があるわけではないのにLIが値を持ってしまうことがあります。なので、0:00:00(UTC)を過ぎたかどうかを判定して、そうでない時のみLIを返すようにしています。
じゃあ逆にうるう秒の日になったとき、うるう秒の日の前日の情報が残っててLIがNoWarningのままないんじゃね?という発想も当然出てくるかと思います。ですが、そういった場合はNTPサーバーに問い合わせない限りわかりませんし、別にそのタイミングはうるう秒が挿入されるわけでもなんでもないのでそんなに大した問題じゃないですね。

IsNowLeapSecond()は、現在うるう秒かどうかを調べるメソッドです。すなわち、23:59:60(UTC)の1秒間のみTRUEを返すメソッドです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
QWORD SNTPGetUTCMilliseconds(void)
{
    QWORD now = SNTPGetLastUpdateUTCMilliseconds() + GetDeltaMs();
 
    if(GetLeapIndicator() != liLastUpdate) {
        switch(liLastUpdate) {
         case LI_Increase:
            return now - 1000;
         case LI_Decrease:
            return now + 1000;
         default:
            break;
        }
    }
    return now;
}

つづいて、UTCの積算ミリ秒を取得するメソッドです。
GetLeapIndicator()は先ほど述べた通りすでにうるう秒を過ぎていたらLI_NoWarningを返しますが、liLastUpdateはNTPサーバーと同期しない限り更新されません。なので、この2者が異なる=うるう秒が過ぎてからNTPサーバーと同期されるまでの間ということになります。その場合はうるう秒に応じて1秒足したり引いたりした値を現在時刻として返しています。

さて、UTCミリ秒を表示するとき、うるう秒で1秒減のときは単に23:59:59(UTC)がスキップされるだけですので全然問題がありません。しかし、1秒増のときは23:59:60(UTC)が挿入されるので通常の手法では表示できませんよね。というわけで、そのあたりの実装をしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void UTCMillisecondToLocaltime(QWORD utcms, int diffmin, struct tm *pTime)
{
    if(pTime != NULL) {
        BOOL IsLeap = IsNowLeapSecond();
 
        if(IsLeap)
            utcms -= 1000;
 
        time_t tick = ((DWORD)(utcms / 1000) + diffmin * 60) & 0x7FFFFFFF;
        struct tm *ptm = localtime(&tick);
        memcpy(pTime, ptm, sizeof(struct tm));
 
        if(IsLeap)
            pTime->tm_sec = 60;
    }
}

diffminはUTCとのずれの分です。JSTはUTC+9なので、9 * 60を渡してもらうということになります。
1秒増のうるう秒のとき、IsLeapがTRUEになります。そのときにはNTP的には翌日0:00:00扱いになるので、とりあえず1秒引いて23:59:59にした上でlocaltime()を呼んでいます。そして、その後にstruct tmのtm_secメンバーを60にして23:59:60を示すようにしました。


これでうるう秒周りの実装が終わりました。





このように、見事にうるう秒で増える場合、増えない場合の動作ができました。

ちなみに、おまけとしてLIの値を表示する機能も追加しておきました。



さて、これで今年の7月1日が楽しみですね。

2015年1月7日水曜日

ウォッチドッグタイマーの盲信(湘南モノレール出発信号冒進事故から学ぶ)

今回はちょっといつもとはテイストの違う記事を書いてみようかなって思います。

もう7年近く前の事故になりますが、湘南モノレールが本来停止すべき西鎌倉駅を止まれずに出発信号を冒進し、対向列車とあわや正面衝突になりかけた事故がありました。

http://jtsb.mlit.go.jp/jtsb/railway/detail.php?id=1744

VVVFインバーターが誤作動して力行状態のままに固定されてしまい、最終的に運転士が非常ブレーキ保安ブレーキを取り扱ったにもかかわらず十分な減速が得られなかったというのがこの事故の原因です。
幸い、死傷者等はいませんでしたが、何重にも安全装置が付けられている鉄道で安全に止まれなかったのかというのは非常に興味深いところではあります。運転士が異常を感じたにもかかわらずただちに停止しようとしなかったというヒューマンエラー的なところから、ノイズを受けやすい配線構造、VVVFインバーターの制御装置がノイズによって異常な信号を受信したときにフェイルセーフにならないプログラムのバグなど、さまざまな原因が絡み合って、最終的にこのような事故といった形で表面に現れてきました。

ここはプログラミング系のブログということもあり、特にそのプログラムのバグのあたりから得られる教訓と設計思想について考えていきたいと思います。


VVVFインバーターの制御プログラムは下図のようになっていたようです。

 (事故調査報告書 P.10図1より引用)

ウォッチドッグタイマー(WDT)はこの手のソフトウェア監視システムとしては非常に一般的なものです。ウォッチドッグ=番犬がメインプロセッサーやクロックから分離された別系統として独自に動作しており、一定時間以上メインプロセッサ側からWDTリセット命令が実行されなかったらシステム全体にリセットを掛ける仕組みになっています(文中で使われる『リセット』という言葉が、WDTをリセットすることなのか、WDTがシステム全体をリセットすることなのかがわかりにくくなっていますが、是非混同しないようにお願いします)。万が一ソフトウェアがハングアップしたり、その他のハードウェア故障等でクロックが停止した場合にリセットを掛けられるということですね。

さて、フローチャートを見ると割り込みによって時間をカウントし、そのカウントされた時間によって10msごとにメインプログラムで加減速シーケンスを処理するようになっています。至ってシンプルで、誰でも作りそうなプログラムです。ここに一体どういう問題が起きたのでしょう。

右側に不正割り込みがあったときの処理が書いてあります。ここで、他の割り込みを禁止するようになっています。どういう意図で割り込みを禁止して戻るだけにしたのか、もしくはハードウェアの制約等でそうなってしまったのかはわかりませんが、こういう仕様になっていたそうです。
また、3両編成の車両の1両のみケーブルの断面積が小さいためノイズがアースに流れにくいなどといった条件も組み合わさった結果、電源装置から発生したノイズがVVVFインバーターの制御装置へ入り込み、それを不正な割り込みとして拾ってしまいました。そうなったら最後、タイマーはインクリメントされないがWDTがリセットされ続けるという状態になってしまい、加減速シーケンスが今後一切処理されることの無いまま、WDTも自らが監視している装置に不具合が起きていることを認識できない状態になってしまいました。そのため、ノイズによる不正割り込みが起こる直前の状態、すなわちこの時は力行状態に固定されてしまったのです。


さて、このトラブルからどういった教訓が得られるでしょうか。

「ウォッチドッグタイマーは一定時間以上リセットされなくなったとき、すなわちハングアップしたときにリセットを掛ける機能」という理解だけでは甘いことがあるということです。「一定時間以上リセットされないのならハングアップした」は必要十分条件ではなく十分条件でしかないということですね。すなわち、「ループのいつも通るところにリセット命令を置いておけばいいや」と機械的にプログラムを書くのでは、本当に有事のときにWDTが正常に作動してくれないことがあるということです。

特に、割り込みはいわばマルチスレッドプログラミングです。例えばタイマー割り込みで一定時間ごとにWDTをリセットするプログラムが意味が無いということはわかるでしょう。なぜならば、メインプログラムがハングしてもプロセッサーが動いてさえいれば割り込みは受け付けられますから延々とWDTはリセットされ続けます。
またその逆でも問題は起こりえます。割り込みですべての処理をしていたとして、メインプログラムでWDTをリセットし続けたら、例えば何らかの要因で割り込みが発生しなくなってしまって一切の処理が行われない状態になってしまっても、WDTは延々とリセットされ続けます。
また、今回のように処理をするにあたる条件かどうかの分岐の外でWDTのリセットを掛けるプログラムにしても問題が起きます。
なので、このような状態ではWDTは何も本来の意味をなさないわけです。WDTのリセット命令を置く位置は、本当に慎重に吟味しなければなりません。


今回のこの事故を受けて、湘南モノレールの車両メーカーは非常ブレーキが投入された場合に主回路を遮断する処理の追加と、WDTのリセット処理を加減速シーケンスの直後への変更を制御プログラムに行ったようです。(事故報告書 P.67 6.2)

前者はよりフェイルセーフを確固なものにするための変更ですね。今まで独立していた非常ブレーキとモーター制御に対して、非常ブレーキ作動時にモーターへ電流が流れないようにプログラムを変更したということですね。

後者はWDTのリセット位置の再検討ですね。これは当然の対応です。

(事故調査報告書 P.10図1を一部編集)

おそらく、事故報告書の文面から上図のようなフローチャートのプログラムに変更されているはずです。これによって、加減速シーケンス処理が一定以上行われていないことがすなわちWDTへのリセット命令が実行されないということになり、今回のケースにおけるバグは解消されました。


また、これ以外にも、運転の取り扱いマニュアルに「非常ブレーキを掛けても減速感が無い場合はレバースハンドル(進行方向を逆転するためのスイッチ)を「切」にする(筆者注:おそらくモーターへの電流を遮断するという意味だと思われる)」といったことも付け加えられたようです。(事故報告書 P.67 6.1)
実はこれはとても重要です。プログラミングをする人ならば、プログラムへのバグの混入は避けられないものであることは承知しているかと思います。どんなにデバッグしても、どんなに動作検証をしても、思いもよらないところでたまにバグることがあるかと思います。もちろん、ソフトウェアの構造設計やプログラミングのスキルによってそれはある程度低減させることはできますが、「100%完璧なプログラムでいかなる状態でも確実に動作します」なんて言える人はどこにもいません。なので、このようにソフトウェアの外で安全を担保する仕組みはとても重要なのです。



さて、それではWDT使用時の注意点をいくつか私なりにまとめてみました。今回の事故から得られる教訓以外にも、重要だと思う点は挙げたつもりです。

プログラムの検証段階からWDTを使用しない

これはとても重要です。まだ検証が十分に行われていないプログラムは余裕でバグります。
しかし、WDTを使用していたら、そのバグによって起こった異常状態からすぐさまリセット処理を行い回復するでしょう。そうなった場合、プログラムのバグが人間の目に届きにくくなります。
WDTによるリセット処理が行われるのは正常な状態ではありません。そのようなリセット処理は出番が無いのが一番です。 なので、WDTの導入はソフトウェアが完成した、もうこれでリリースできるぞというときに導入すべきです。

WDTのリセット命令を複数か所に入れない

これもとても重要です。複数か所にリセット命令を入れてしまうと、万が一1か所で問題が起きても他の場所に設置されたリセット命令でWDTがリセットされ続け、正常にWDTによるリセット処理がされないという状態になりえます。WDTのリセット命令は1か所だけ、これは鉄則です。

割り込み処理中にWDTリセット命令を置かない

これは上でも少し触れました。割り込みはメインプログラムで何が起きていようと発生するため、割り込み処理中にWDTリセットを行うとメインプログラムでのハングアップに気づけないことが十分にあり得ます。なので、WDTのリセット命令はもっとも優先度の低いプログラムに置くというのは鉄則です。
また、今回の事故のように、メインプログラムでも割り込みに依存する場所で問題が起きる場合が考えられるので、そのあたりも含めて慎重に検討しましょう。

WDTリセット命令はクリティカルな処理の近くに置く(不用意にリセットしまくらない)

これが今回の湘南モノレールの事故を踏まえた教訓ですね。
クリティカルな処理を行うかどうかの分岐の外側にWDTリセット命令を設置してしまったがために、 クリティカルな処理が行われないままWDTがリセットしまくられるということになってしまいました。なので、クリティカルな処理の近くで、かつ周期的にリセットがかけられる位置に置くべきです。
「クリティカルな処理は不定期に行われる」という場合は困りますね。ケースにもよるでしょうが、どこかその周辺の定期的に呼ばれる場所に置くしかないんですかね。ちなみに、「クリティカルな処理が行われる周期が大きすぎてWDTのリセット周期に間に合わない」とか「複数クリティカルな処理がある」といったケースはもはや信頼性を求められる機器では設計を考え直せという話になるかと思います。

WDTを盲信しない

最後にこれです。異常をソフトウェア外で感知した場合、もしくは人間が緊急停止の操作をした場合などは、ハードウェアレベルでそれを優先し安全に停止するようなシステムを検討すべきです。WDTによって想定しえないトラブルから回復できるかもしれないですが、でもそれは万能ではありません。あくまでも保険だという気持ちを持って、WDTを信用しすぎないことが重要です。想定しえないようなトラブル時に復帰する機能が正常に動くかなんて検証することすら難しいですもんね。


いかがでしょうか。

まあ、私は趣味のプログラマーなのでそこまで人命にかかわるような装置の組み込みプログラムを書くことは滅多に無いのですが、でも安全は大切です。WDTを導入するようなプログラムを書くときは、是非ともこれくらいは頭に入れながら設計したいものですね。
もしかしたら、現場のプロのプログラマーの間ではこれくらい当たり前で、さらにもっと体系化された知識があるのかもしれません。そういう情報を入手できる機会があったら是非ともまた吸収していきたいなと思います。


あと、盲信は禁物です。この記事を読んでWDTを導入したけどうまく動作しなくて命の危機に見舞われたとか、そんなことの無いように、あくまでも自分で責任を持って考えながら、もしくは勉強しながらやっていただけたらなと思います。