Можно ли в функции возврата yield гарантировать, что финализатор вызывается в том же потоке?

У меня есть сложная проблема, которая возникает в некоторых частях моего кода. У меня есть диспетчер кеша, который либо возвращает элементы из кеша, либо вызывает делегата для их создания (дорого).

Я обнаружил, что у меня проблемы с финальной частью моего метода, которая запускается в другом потоке, чем остальные.

Вот урезанная версия

public IEnumerable<Tuple<string, T>> CacheGetBatchT<T>(IEnumerable<string> ids, BatchFuncT<T> factory_fn) where T : class
    {

        Dictionary<string, LockPoolItem> missing = new Dictionary<string, LockPoolItem>();

        try
        {
            foreach (string id in ids.Distinct())
            {
                LockPoolItem lk = AcquireLock(id);
                T item;

                item = (T)resCache.GetData(id); // try and get from cache
                if (item != null)
                {
                    ReleaseLock(lk);
                    yield return new Tuple<string, T>(id, item);
                }
                else
                    missing.Add(id, lk);                    
            }

            foreach (Tuple<string, T> i in factory_fn(missing.Keys.ToList()))
            {
                resCache.Add(i.Item1, i.Item2);
                yield return i;
            }

            yield break;                        // why is this needed?
        }
        finally
        {
            foreach (string s in missing.Keys)
            {
                ReleaseLock(l);
            }
        }
    }

Блокировка Acquire and Release заполняет словарь объектами LockPoolItem, которые были заблокированы с помощью Monitor.Enter/Monitor.Exit [я также пробовал мьютексы]. Проблема возникает, когда ReleaseLock вызывается в потоке, отличном от того, в котором был вызван AcquireLock.

Проблема возникает при вызове этого из другой функции, которая использует потоки, иногда вызывается блок finalize из-за удаления IEnumerator, работающего на возвращаемой итерации.

Следующий блок является простым примером.

BlockingCollection<Tuple<Guid, int>> c = new BlockingCollection<Tuple<Guid,int>>();

            using (IEnumerator<Tuple<Guid, int>> iter = global.NarrowItemResultRepository.Narrow_GetCount_Batch(userData.NarrowItems, dicId2Nar.Values).GetEnumerator()) {
                Task.Factory.StartNew(() => {

                    while (iter.MoveNext()) {
                        c.Add(iter.Current);
                    }
                    c.CompleteAdding();
                });
            }

Кажется, этого не происходит, когда я добавляю разрыв выхода, однако мне трудно отлаживать это, поскольку это происходит очень редко. Тем не менее, это происходит - я пытался регистрировать идентификаторы потоков и завершать работу, если меня вызывают в разных потоках...

Я уверен, что это не может быть правильным поведением: я не понимаю, почему метод dispose (т.е. выход из использования) вызывается в другом потоке.

Любые идеи, как защититься от этого?


person dominicbeesley    schedule 24.04.2015    source источник
comment
Я бы предположил, что любой дизайн, в котором вы удерживаете блокировки в точке, в которой вы даете выход, является сломанным - вы понятия не имеете, сколько времени пройдет, прежде чем ваш вызывающий абонент в следующий раз вызовет MoveNext или, действительно, как вы обнаружили, Dispose. Не зная больше о вашей конкретной проблеме, трудно дать конкретный совет, но именно на это я бы обратил внимание - измените дизайн, чтобы вы не зависели от милости вызывающих абонентов, когда вы снимаете блокировки.   -  person Damien_The_Unbeliever    schedule 24.04.2015
comment
Это справедливое замечание, однако оно не отвечает на вопрос. Чего я пытаюсь добиться, так это того, чтобы провайдер возвращал элементы по мере их извлечения из медленного хранилища - некоторые элементы могут занимать секунды, другие миллисекунды, но нет возможности заранее узнать, какие из них в пакете будут медленно возвращаться. Я подозреваю, что было бы лучше, если бы блокирующая коллекция была предоставлена ​​​​функции кэширования и заполнена ею. Однако я до сих пор не понимаю, почему dispose/finalize вызывается в другом потоке.   -  person dominicbeesley    schedule 27.04.2015
comment
Нет, это не так, поэтому это опубликовано как комментарий. Если вы готовы внести такие изменения, я мог бы приложить некоторые усилия и показать вам, как может выглядеть альтернатива. Если вы проверите обсуждение, которое я провел под ответом supercat, вы увидите, что я уже несколько дней утверждаю, что нет никакой гарантии, что перечисляемое число возобновится в том же потоке (особенно если вызывающий код использует современные функции, такие как async )   -  person Damien_The_Unbeliever    schedule 27.04.2015
comment
Спасибо, Дэмиен, у меня есть много идей, как это улучшить. Я просто не мог понять, почему Dispose/finalize вызывается в другом потоке, когда он находится в блоке использования. Сейчас я собираюсь удалить и перезапустить свою модель кэширования.   -  person dominicbeesley    schedule 27.04.2015


Ответы (3)


Кажется, здесь идет гонка.

Похоже, ваш вызывающий код создает перечислитель, затем запускает задачу в пуле потоков для его перечисления, а затем удаляет перечислитель. Мои первоначальные мысли:

  • Если перечислитель удаляется до начала перечисления, ничего не произойдет. Из краткого теста это не мешает перечислению после его удаления.

  • Если перечислитель удаляется во время перечисления, будет вызван блок finally (в вызывающем потоке), и перечисление остановится.

  • Если перечисление завершено действием задачи, будет вызван блок finally (в потоке пула потоков).

Чтобы попытаться продемонстрировать, рассмотрим этот метод:

private static IEnumerable<int> Items()
{            
    try
    {
        Console.WriteLine("Before 0");

        yield return 0;

        Console.WriteLine("Before 1");

        yield return 1;

        Console.WriteLine("After 1");
    }
    finally 
    {
        Console.WriteLine("Finally");
    }
}

Если вы распоряжаетесь перед перечислением, в консоль ничего не будет записано. Это то, что я подозреваю, что вы будете делать большую часть времени, так как текущий поток достигает конца блока using до начала задачи:

var enumerator = Items().GetEnumerator();
enumerator.Dispose();    

Если перечисление завершится до Dispose, последний вызов MoveNext вызовет блок finally.

var enumerator = Items().GetEnumerator();
enumerator.MoveNext();
enumerator.MoveNext();
enumerator.MoveNext();

Результат:

"Before 0"
"Before 1"
"After 1"
"Finally"

Если вы распоряжаетесь во время перечисления, вызов Dispose вызовет блок finally:

var enumerator = Items().GetEnumerator();
enumerator.MoveNext();
enumerator.Dispose();

Результат:

"Before 0"
"Finally"

Я предлагаю вам создать, перечислить и удалить перечислитель в одном потоке.

person Charles Mager    schedule 24.04.2015
comment
Но я думал, что Dispose нужно вызывать в конце блока использования — во внешнем потоке. Вы говорите, что .MoveNext() может неявно вызывать Dispose? Я предполагал, что Dispose был вызван в точке, где закрытие } используемого блока находится, т.е. в исходном внешнем потоке. - person dominicbeesley; 27.04.2015
comment
Dispose будет вызываться в конце блока using, проблема в том, что выполнение, вероятно, достигнет этой точки еще до того, как код в Task начнет выполняться. MoveNext может вызвать блок finally из-за того, что больше нечего делать, иначе вызов Dispose будет вызван до завершения перечисления. - person Charles Mager; 27.04.2015
comment
@dominicbeesley Я обновил ответ, чтобы, надеюсь, уточнить. - person Charles Mager; 27.04.2015
comment
Спасибо, Чарльз, теперь это имеет смысл - я не могу поверить, что у меня не было в голове этого, но теперь было указано, что это единственный способ, которым это может работать - ох! Я пытался проголосовать за вас, но моя репутация слишком низкая. - person dominicbeesley; 27.04.2015

Благодаря всем ответам я понял, что происходит и почему. Тогда решение моей проблемы было довольно простым. Мне просто нужно было убедиться, что все вызывается в одном потоке.

        BlockingCollection<Tuple<Guid, int>> c = new BlockingCollection<Tuple<Guid,int>>();

        Task.Factory.StartNew(() => {
            using (IEnumerator<Tuple<Guid, int>> iter = global.NarrowItemResultRepository.Narrow_GetCount_Batch(userData.NarrowItems, dicId2Nar.Values).GetEnumerator()) {

                while (iter.MoveNext()) {
                    c.Add(iter.Current);
                }
                c.CompleteAdding();
            }
        });
person dominicbeesley    schedule 28.04.2015

Термин «финализатор» относится к понятию, совершенно не связанному с «блоком «наконец»»; ничто в контексте многопоточности финализаторов не гарантируется, но я думаю, что вас действительно интересуют блоки «наконец».

Блок finally, окруженный блоком yield return, будет выполняться любым потоком, вызывающим Dispose в перечислителе итератора. Перечислители, как правило, имеют право предполагать, что все операции, выполняемые над ними, включая Dispose, будут выполняться тем же потоком, который их создал, и, как правило, не обязаны вести себя каким-либо образом, хотя бы отдаленно напоминающим разумный, в тех случаях, когда это не так. Система не запрещает коду использовать перечислители в нескольких потоках, но в случае, если программа использует перечислитель из нескольких потоков, который не обещает, что он будет работать в этом отношении, это означает, что любые вытекающие из этого последствия не являются ошибкой перечислителя. , а скорее по вине программы, которая использовала его нелегитимно.

Как правило, для классов полезно включать достаточную защиту от недопустимой многопоточности, чтобы гарантировать, что неправильное многопоточное использование не приведет к уязвимостям безопасности, но не беспокойтесь о предотвращении любого другого вреда или путаницы.

person supercat    schedule 24.04.2015
comment
Я бы поставил под сомнение ваши предположения здесь. Есть ли у вас какие-либо конкретные рекомендации или документация, подтверждающая их? Почему вы утверждаете, что счетчикам разрешен доступ из одного потока? - person Damien_The_Unbeliever; 24.04.2015
comment
Здесь я имею в виду, в частности, методы async — нет никакой гарантии (в целом), что весь код внутри этого метода будет выполняться в одном потоке, и все же я не знаю ни одного руководство, в котором говорится, что не используйте foreach в асинхронных методах - person Damien_The_Unbeliever; 24.04.2015
comment
@Damien_The_Unbeliever: Использование foreach полностью в асинхронном методе, насколько я понимаю, приведет к вызову GetEnumerator из неизвестного контекста потоковой передачи, но приведет к тому, что все последующие действия с этим перечислителем будут выполняться в том же контексте потоковой передачи. - person supercat; 24.04.2015
comment
@Damien_The_Unbeliever: Обычно ссылки типа IEnumerator<T> редко передаются; Вместо этого передается IEnumerable<T>. Хотя в некоторых редких случаях может быть полезно передать IEnumerator<T> в асинхронный метод и убедиться, что это сделано с ним, прежде чем что-либо еще попытается его использовать (многие перечислители Framework определенно не потокобезопасны для одновременный доступ) я не считаю это очень ценной способностью; если бы такое использование ожидалось, я не думаю, что язык позволил бы yield return внутри lock. - person supercat; 24.04.2015
comment
Мы не говорим об одновременном доступе, мы говорим о доступе из разных потоков в разное (четко определенное) время. - person Damien_The_Unbeliever; 24.04.2015
comment
@Damien_The_Unbeliever: Это сценарий, который я описал как возможно смутно полезный; yield return внутри lock при таком сценарии сломался бы, и я не думаю, что авторы языка допустили бы такое, если бы предполагался неодновременный доступ отдельными потоками. - person supercat; 24.04.2015
comment
Возможно, проверьте этот ответ: также наихудшая практика - делать yield return внутри блокировки по той же причине. Это законно, но я бы хотел, чтобы мы сделали это незаконным. Мы не собираемся совершать ту же ошибку для await. - person Damien_The_Unbeliever; 24.04.2015
comment
@Damien_The_Unbeliever: я не говорю, что yield return внутри замка — хорошая идея; моя точка зрения заключалась в том, что эта концепция бессмысленна без ожидания того, что все обращения к перечислителю (не обязательно перечисляемому) будут осуществляться через один и тот же поток. Заставить компилятор C# разрешить yield return внутри lock, по-видимому, потребовало некоторой работы; Я сомневаюсь, что люди, занимающиеся компиляцией, потратили бы такие усилия, если бы нельзя было ожидать, что сгенерированный код будет осмысленным. - person supercat; 25.04.2015
comment
Я только что написал простое перечисление, которое возвращает целые числа 1, 2 и 3, сообщая, в каком потоке он находится между каждым yield. Затем я написал метод main, который Thread.Runs является методом async со следующим циклом foreach. Он сообщил о разных идентификаторах потоков. Демонстрация того, что foreach внутри async ничего не делает для поддержания сходства потоков. ` foreach (var x в новом ExEnum()) { Console.WriteLine(Got {0}, x); ожидание Task.Delay(10); }` - person Damien_The_Unbeliever; 26.04.2015
comment
@Damien_The_Unbeliever: Если бы итератор распечатывал сообщения до и после Thread.Sleep(1000);, каков был бы эффект? Что именно система пытается с чем совместить? Я никогда не использовал асинхронное кодирование, но я ожидал, что не система будет делать что-то для поддержания сходства потоков, а скорее, что если цель состоит в том, чтобы перекрыть обработку элемента с выборкой следующего, то обработка должно быть выполнено в другом потоке, в то время как выборка следующего должна быть сделана в текущем потоке. Я немного удивлен, что система этого не делает. - person supercat; 27.04.2015
comment
@Damien_The_Unbeliever: Можете ли вы объяснить, что произойдет, если итератору потребуется значительное количество времени для получения каждого элемента? Учитывая, что IEnumerable намного старше, чем асинхронные методы, и что шаблоны использования, которые гарантировали бы неодновременные действия в нескольких потоках, исторически были исключительно редки, можете ли вы указать какие-либо доказательства того, что реализации IEnumerator<T> не должны были предполагать согласованность? контекст потоков? - person supercat; 28.04.2015