いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Synchronizedしたコレクションの操作 (Collection, Queue, Synchronized, IsSynchronized, lock, CopyTo)


tekkさんの日記の方で、同期化コレクションについて記述されています。


その中で、Collection.Synchronizedメソッドについて触れられているのですが、一点補足みたいなものです。
同期化コレクションの作成については、tekkさんの方でわかりやすく書かれています。


Synchronizedメソッドで、スレッドセーフなコレクションは作成できるのですが、このスレッドセーフは
単一の操作に対してのという意味です。


つまり、AddやEnqueue、Dequeueなどの単独の操作に対してはスレッドセーフを保証してくれます。
でも、イテレーションやナビゲーション、プット・イフ・アブセント(PutIfAbsent)のような複合アクションの
場合は保証されません。


一番わかりやすいのが、イテレーションです。
2つのスレッドがあるとして、片方がコレクションの列挙を、もう片方がコレクションの操作を行っている場合
Synchronizedコレクションであっても、ちゃんとロックを施して、同期させる必要があります。
(列挙処理中にコレクションの内容が変更されると例外が発生します。)


この辺は、Javaでも.NETでも同じです。


例を挙げると

    public class QueueSynchronizedSamples01 : IExecutable {

        Queue queue;

        public void Execute() {

            queue = Queue.Synchronized(new Queue());
            Console.WriteLine("Queue.IsSyncronized == {0}", queue.IsSynchronized);

            for(int i = 0; i < 1000; i++) {
                queue.Enqueue(i);
            }

            new Thread(EnumerateCollection).Start();
            new Thread(ModifyCollection).Start();

            Console.WriteLine("Press any key to exit...");
            Console.ReadLine();
        }

        void EnumerateCollection() {

            //
            // ロックせずに列挙処理を行う。
            //
            // CollectionのSynchronizedメソッドで作成したオブジェクトは
            // 単一操作に対しては、同期できるが複合アクションはガードできない。
            // (イテレーション、ナビゲーション、プット・イフ・アブセントなど)
            //
            // 別のスレッドにて、コレクションを操作している場合
            // 例外が発生する可能性がある。
            //
            foreach(int i in queue) {
                Console.WriteLine(i);
                Thread.Sleep(0);
            }
        }

        void ModifyCollection() {

            for(;;) {
                if(queue.Count == 0) {
                    break;
                }

                Console.WriteLine("\t==> Dequeue");
                queue.Dequeue();

                Thread.Sleep(0);
            }

        }
    }

の場合、EnumerateCollectionメソッドの中でコレクションを列挙していますが
別のスレッドで、Dequeueをしているので、例外が発生する可能性があります。
上記のサンプルだと、かなりの確率で例外が発生するはずです。


Thread.Sleepは、わざとタイムスライスを切り替える為に入れています。


で、例外を発生させずに列挙するには以下のようにロックします。

        lock(queue.SyncRoot) {
            foreach(int i in queue) {
                Console.WriteLine(i);
                Thread.Sleep(0);
            }
        }

ただ、これだと列挙処理中はずっとコレクションがロックされた状態となります。
つまり、別スレッドで操作が行う事が出来なくなります。


もう一つの方法は、一旦ロックするのですが、その中でクローンを作成し
クローン作成後、ロックを解放します。列挙処理を行うのはクローンに対して行います。

            Queue cloneQueue = null;
            lock(queue.SyncRoot) {

                Array array = Array.CreateInstance(typeof(int), queue.Count);
                queue.CopyTo(array, 0);

                cloneQueue = new Queue(array);
            }

            foreach(int i in cloneQueue) {
                Console.WriteLine(i);
                Thread.Sleep(0);
            }

この場合、ロックしているのはクローンを作成している間だけとなります。
ただし、コレクション自体が非常に大きい場合は、クローンを作成する処理自体で
時間がかかってしまいますが、それはトレードオフと考えます。


どちらも、並行処理では一般的な手法ですね。



以下、サンプルです。

#region QueueSynchronizedSamples-01
    public class QueueSynchronizedSamples01 : IExecutable {

        Queue queue;

        public void Execute() {

            queue = Queue.Synchronized(new Queue());
            Console.WriteLine("Queue.IsSyncronized == {0}", queue.IsSynchronized);

            for(int i = 0; i < 1000; i++) {
                queue.Enqueue(i);
            }

            new Thread(EnumerateCollection).Start();
            new Thread(ModifyCollection).Start();

            Console.WriteLine("Press any key to exit...");
            Console.ReadLine();
        }

        void EnumerateCollection() {

            //
            // ロックせずに列挙処理を行う。
            //
            // CollectionのSynchronizedメソッドで作成したオブジェクトは
            // 単一操作に対しては、同期できるが複合アクションはガードできない。
            // (イテレーション、ナビゲーション、プット・イフ・アブセントなど)
            //
            // 別のスレッドにて、コレクションを操作している場合
            // 例外が発生する可能性がある。
            //
            /*
            foreach(int i in queue) {
                Console.WriteLine(i);
                Thread.Sleep(0);
            }
            */

            //
            // 第一の方法:
            //
            // ループしている間、コレクションをロックする.
            // 有効であるが、列挙処理を行っている間ずっとロックされたままとなる。
            // 
            /*
            lock(queue.SyncRoot) {
                foreach(int i in queue) {
                    Console.WriteLine(i);
                    Thread.Sleep(0);
                }
            }
            */

            //
            // 第二の方法:
            //
            // 一旦ロックを獲得し、コレクションのクローンを作成する。
            // クローン作成後、ロックを解放し、その後クローンに対して列挙処理を行う。
            //
            // これもコレクション自体が大きい場合は時間と負荷がかかるが、それはトレードオフとなる。
            //
            Queue cloneQueue = null;
            lock(queue.SyncRoot) {

                Array array = Array.CreateInstance(typeof(int), queue.Count);
                queue.CopyTo(array, 0);

                cloneQueue = new Queue(array);
            }

            foreach(int i in cloneQueue) {
                Console.WriteLine(i);

                // わざとタイムスライスを切り替え
                Thread.Sleep(0);
            }
        }

        void ModifyCollection() {

            for(;;) {
                if(queue.Count == 0) {
                    break;
                }

                Console.WriteLine("\t==> Dequeue");
                queue.Dequeue();

                // わざとタイムスライスを切り替え
                Thread.Sleep(0);
            }

        }
    }
#endregion


ちなみに、ジェネリックなコレクションの場合はSystem.Collections.Generic.SynchronizedReadOnlyCollectionクラスを
利用するのが便利です。(3.0から追加されています。)