VB のたまご

作成日: 2017/03/27, 更新日: 2017/03/27


イベントに関するメモリリークを学ぶ2

  •  前回の続きです。

  •  プログラマーが破棄処理を書かずとも、メモリリークが発生しないように守ってくれているのにも関わらず、 【不要メモリ分を自動回収してくれても、なおメモリリークが発生する】というのはどういう状況なのでしょうか?

  • スポンサーリンク


  •  次のサンプルを見てみましょう。このサンプルでは、画面を持つアプリケーションを扱う際に発生する可能性がある構成のサンプルです。 画面があって、ボタンを配置して、イベント処理を書く。これを疑似的に再現したサンプルアプリケーションです。C# / VB それぞれ記載しています。

  •  C# の場合

  • using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace ConsoleApplication3
    {
        // 疑似ボタンコントロールのつもり、クリックイベントを持つ
        class DummyButton
        {
            public event EventHandler Click;
    
            public void RaiseClick()
            {
                var handler = Click;
                if (handler != null)
                    handler(this, EventArgs.Empty);
            }
        }
    }
    

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace ConsoleApplication3
    {
        // 疑似画面クラスのつもり、ボタンのクリックイベント処理をしたいため、イベントハンドラを持つ
        class DummyForm
        {
            public void DummyButton_Click(object sender, EventArgs e)
            {
                Console.WriteLine("DummyForm クラスの DummyButton_Click メソッドが呼ばれました");
            }
        }
    }
    

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    
    namespace ConsoleApplication3
    {
        class Program
        {
            static void Main(string[] args)
            {
                // 以降では、GC(ガベージコレクション)にメモリ回収をお任せするのは辛いので、
                // 強制的にメモリ回収をして不要メモリ回収後、使用中のメモリサイズを表示します。
                Action doClean = () => Console.WriteLine($"{GC.GetTotalMemory(true).ToString("N")} bytes");
    
                // ここからは、イベントに関するメモリリークを見ていきましょう。
                // 1回毎に doClean メソッドを呼び出して、使われなくなったオブジェクトのメモリ解放をします。
                // 普通に考えるとメモリ解放されるので、どんどんメモリ使用量が増えることは無いはずです。
                // パターン別に実験するごとに、ベースとなるメモリ使用量が増えているのは目をつぶってくださいorz
    
                // ボタンと画面の寿命(有効期間)が同じ場合、メモリリークは発生しない
                {
                    for (var i = 0; i < 10; i++)
                    {
                        var ctrl = new DummyButton();
                        var frm = new DummyForm();
                        ctrl.Click += frm.DummyButton_Click;
                        doClean();
                    }
                }
                Console.WriteLine();
    
                // ボタン<画面の場合(イベントハンドラを持つクラスの方が、イベントを持つクラスより長生き)、メモリリークは発生しない
                {
                    var frm = new DummyForm();
                    for(var i = 0; i < 10; i++)
                    {
                        var ctrl = new DummyButton();
                        ctrl.Click += frm.DummyButton_Click;
                        doClean();
                    }
                }
                Console.WriteLine();
    
                // ボタン>画面の場合(イベントを持つクラスの方が、イベントハンドラを持つクラスよりも長生き)、メモリリークが発生する
                {
                    var ctrl = new DummyButton();
                    for(var i = 0; i < 10; i++)
                    {
                        var frm = new DummyForm();
                        ctrl.Click += frm.DummyButton_Click;
                        doClean();
                    }
    
                    // もし DummyForm クラスのインスタンスが破棄されているならば、イベントハンドラは呼ばれないはず
                    ctrl.RaiseClick();
                }
                Console.WriteLine();
    
                // メモリリークするくらいメモリ使用量が多くないので OutOfMemoryException は発生しないが、
                // この状態を繰り返すような実装の仕方をしている場合、いつか、突然謎の例外エラーが出ることと思います。
                
    
                Console.Read();
            }
        }
    }
    

  •  VB の場合

  • ' 疑似ボタンコントロールのつもり、クリックイベントを持つ
    Public Class DummyButton
    
        Public Event Click As EventHandler
    
        Public Sub RaiseClick()
    
            RaiseEvent Click(Me, EventArgs.Empty)
    
        End Sub
    
    End Class
    

    ' 疑似画面クラスのつもり、ボタンのクリックイベント処理をしたいため、イベントハンドラを持つ
    Public Class DummyForm
    
        Public Sub DummyButton_Click(sender As Object, e As EventArgs)
    
            Console.WriteLine("DummyForm クラスの DummyButton_Click メソッドが呼ばれました")
    
        End Sub
    
    End Class
    

    Module Module1
    
        Sub Main()
    
            ' 以降では、GC(ガベージコレクション)にメモリ回収をお任せするのは辛いので、
            ' 強制的にメモリ回収をして不要メモリ回収後、使用中のメモリサイズを表示します。
            Dim doClean As Action = Sub() Console.WriteLine($"{GC.GetTotalMemory(True).ToString("N")} bytes")
    
            ' ここからは、イベントに関するメモリリークを見ていきましょう。
            ' 1回毎に doClean メソッドを呼び出して、使われなくなったオブジェクトのメモリ解放をします。
            ' 普通に考えるとメモリ解放されるので、どんどんメモリ使用量が増えることは無いはずです。
            ' パターン別に実験するごとに、ベースとなるメモリ使用量が増えているのは目をつぶってくださいorz
    
            ' ボタンと画面の寿命(有効期間)が同じ場合、メモリリークは発生しない
            With Nothing
    
                For i As Integer = 0 To 10
    
                    Dim ctrl = New DummyButton
                    Dim frm = New DummyForm
                    AddHandler ctrl.Click, AddressOf frm.DummyButton_Click
                    doClean()
    
                Next
    
            End With
            Console.WriteLine()
    
            ' ボタン<画面の場合(イベントハンドラを持つクラスの方が、イベントを持つクラスより長生き)、メモリリークは発生しない
            With Nothing
    
                Dim frm = New DummyForm
                For i As Integer = 0 To 10
    
                    Dim ctrl = New DummyButton
                    AddHandler ctrl.Click, AddressOf frm.DummyButton_Click
                    doClean()
    
                Next
    
            End With
            Console.WriteLine()
    
            ' ボタン>画面の場合(イベントを持つクラスの方が、イベントハンドラを持つクラスよりも長生き)、メモリリークが発生する
            With Nothing
    
                Dim ctrl = New DummyButton
                For i As Integer = 0 To 10
    
                    Dim frm = New DummyForm
                    AddHandler ctrl.Click, AddressOf frm.DummyButton_Click
                    doClean()
    
                Next
    
                ' もし DummyForm クラスのインスタンスが破棄されているならば、イベントハンドラは呼ばれないはず
                ctrl.RaiseClick()
    
            End With
            Console.WriteLine()
    
            ' メモリリークするくらいメモリ使用量が多くないので OutOfMemoryException は発生しないが、
            ' この状態を繰り返すような実装の仕方をしている場合、いつか、突然謎の例外エラーが出ることと思います。
    
    
            Console.Read()
        End Sub
    
    End Module
    

    出力結果
    86,484.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    90,772.00 bytes
    
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    90,828.00 bytes
    
    90,884.00 bytes
    90,980.00 bytes
    91,032.00 bytes
    91,076.00 bytes
    91,136.00 bytes
    91,180.00 bytes
    91,224.00 bytes
    91,268.00 bytes
    91,344.00 bytes
    91,388.00 bytes
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    DummyForm クラスの DummyButton_Click メソッドが呼ばれました
    

  •  パターン別に動かすたびに基準となるメモリ使用量が増えていますが、謎ですorz。メモリリークではないと思うのですが。 分かり次第追記させていただきます。申し訳ないです。

  •  この謎現象を抜きして、続きを説明させていただきますと、最初の2パターンは、初回こそ違うものの、常に一定値のメモリ使用量をキープしていることが分かります。 これはつまり、増加した分を回収して返却(メモリ解放による減少)しているからです。

  •  これに対して3つ目のパターンは、1回毎に増加しっぱなしで増えていること、イベントハンドラが呼ばれているのを見ると、 メモリ解放されていない(不要メモリが回収されていない)ことが分かります。

  •  ちなみにこのサンプルではメモリリークは発生しないですが、これを繰り返していれば、いづれはメモリリークが発生することは想像できます。

  • スポンサーリンク


  •  もう一つ見てみましょう。今度は WinForms アプリケーションです。 Button を押すと、タイマーを利用して1秒後に Label に文字列を表示させるアプリケーションです。 また、Button を押すたびに、(不要メモリ回収後の)メモリ使用量を出力します。

  • イメージ
  •  C# の場合

  • using System;
    using System.Collections.Generic;
    using System.ComponentModel;
    using System.Data;
    using System.Drawing;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    using System.Windows.Forms;
    
    namespace ConsoleApplication3.WindowsFormsApplication1
    {
        // イベントに関するメモリリークを再現させるサンプル
        public partial class Form1 : Form
        {
            public Form1()
            {
                InitializeComponent();
            }
    
    
    
            Timer timer = null;
            DataGridView grid = null;
    
            private void button1_Click(object sender, EventArgs e)
            {
                // 未使用になったメモリを回収して、現在使用中のメモリ使用量を表示
                Console.WriteLine($"{GC.GetTotalMemory(true).ToString("N")} bytes");
    
                // 二度押し防止と表示欄クリア
                this.button1.Enabled = false;
                this.label1.Text = string.Empty;
    
                // メモリリーク生成例
                if(timer == null)
                    timer = new Timer();
                timer.Interval = 1000;
                timer.Tick += (sender2, e2) =>
                {
                    this.label1.Text = "1秒経ったよ!";
                    this.button1.Enabled = true;
                    timer.Stop();
                };
                timer.Start();
    
                //// メモリリーク待ちが大変なため、同じような構成を追加
                //// このコメントアウトを外して、何回かボタンを押すと OutOfMemoryException が発生します
                //grid = new DataGridView();
                //for (var i = 0; i < 100; i++)
                //    for (var k = 0; k < 1000000; k++)
                //        grid.CellContentClick += (sender2, e2) => Console.WriteLine();
    
                // 本サンプルのような構成では、これだけメモリ使用量を増加させないとメモリリークを発生させることができませんでした。
                // よって、業務用アプリで発生するイベントに関するメモリリークは、長時間運用することによって(こつこつ溜めて)、発生するのだと思います。
                // これが業務アプリを使っているユーザーからすると「突然例外エラーが出た」となり、
                // 再現性が無く、プログラマーを永遠に困らせる事につながるのでしょうね(悲しいorz)
    
                ////  それこそ業務アプリならもうちょっと複雑でたくさんメモリを使うと思うので、インスタンス生成しすぎも絡んでいるのでは?とか思ったりして
                //for (var i = 0; i < 1000000; i++)
                //{
                //    var ctrl = new DataGridView();
                //}
    
            }
        }
    }
    

  •  VB の場合

  • ' イベントに関するメモリリークを再現させるサンプル
    Public Class Form1
    
    
        Private timer1 As Timer = Nothing
        Private grid1 As DataGridView = Nothing
    
        Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
    
            ' 未使用になったメモリを回収して、現在使用中のメモリ使用量を表示
            Console.WriteLine($"{GC.GetTotalMemory(True).ToString("N")} bytes")
    
            ' 二度押し防止と表示欄クリア
            Me.Button1.Enabled = False
            Me.Label1.Text = String.Empty
    
            ' メモリリーク生成例
            If timer1 Is Nothing Then
                timer1 = New Timer
            End If
    
            timer1.Interval = 1000
            AddHandler timer1.Tick, Sub(sender2, e2)
    
                                        Me.Label1.Text = "1秒経ったよ!"
                                        Me.Button1.Enabled = True
                                        timer1.Stop()
    
                                    End Sub
            timer1.Start()
    
            '' メモリリーク待ちが大変なため、同じような構成を追加
            '' このコメントアウトを外して、何回かボタンを押すと OutOfMemoryException が発生します
            'grid1 = New DataGridView
            'For i As Integer = 0 To 100
            '    For k As Integer = 0 To 1000000
    
            '        AddHandler grid1.CellContentClick, Sub(sender2, e2) Console.WriteLine()
    
            '    Next
            'Next
    
            ' 本サンプルのような構成では、これだけメモリ使用量を増加させないとメモリリークを発生させることができませんでした。
            ' よって、業務用アプリで発生するイベントに関するメモリリークは、長時間運用することによって(こつこつ溜めて)、発生するのだと思います。
            ' これが業務アプリを使っているユーザーからすると「突然例外エラーが出た」となり、
            ' 再現性が無く、プログラマーを永遠に困らせる事につながるのでしょうね(悲しいorz)
    
            '' それこそ業務アプリならもうちょっと複雑でたくさんメモリを使うと思うので、インスタンス生成しすぎも絡んでいるのでは?とか思ったりして
            'For i As Integer = 0 To 1000000
    
            '    Dim ctrl = New DataGridView
    
            'Next
    
        End Sub
    
    End Class
    

    出力結果
    96,452.00 bytes
    101,164.00 bytes
    101,272.00 bytes
    101,312.00 bytes
    101,344.00 bytes
    

  •  何度もボタンを押すと、そのたびにメモリ使用量が増加していくのが分かります。 ちょっと実装に無理やり感が感じられますが、何回もイベント処理を登録してしまっている事が原因です。 一回のイベント登録で数百バイトくらいの増加量なので、手っ取り早くメモリリークを発生させるためコメントアウトしている追加処理を用意しています。

  •  イベントは、登録したイベントハンドラを参照しています(捕まえている状態)。(イベントハンドラを持つ)クラスが不要になったとしても、掴まれている(使われている)のだからメモリ回収はできません。 ここで言うと、Timer コントロールの Tick イベントに紐づけたラムダ式のイベントハンドラですね。 しかも、このラムダ式(や前例の3番目のパターン、ループ内で定義した DummyForm 型の frm 変数)は、もうアクセスできなくなっています。 イベント処理の解除ができていない事が、こつこつと【回収できない不要メモリ】を増加させているという事です。

  •  そう、この【回収できない不要メモリ】が全ての元凶です。

  •  次回に続きます。

  • スポンサーリンク