Скільки роботи потрібно розмістити всередині оператора блокування?


27

Я - молодший розробник, який працює над написанням оновлення програмного забезпечення, яке отримує дані від стороннього рішення, зберігає їх у базі даних, а потім обробляє дані для використання іншим стороннім рішенням. Наше програмне забезпечення працює як служба Windows.

Переглядаючи код попередньої версії, я бачу таке:

        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach(int SomeObject in ListOfObjects)
        {
            lock (_workerLocker)
            {
                while (_runningWorkers >= MaxSimultaneousThreads)
                {
                    Monitor.Wait(_workerLocker);
                }
            }

            // check to see if the service has been stopped. If yes, then exit
            if (this.IsRunning() == false)
            {
                break;
            }

            lock (_workerLocker)
            {
                _runningWorkers++;
            }

            ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);

        }

Логіка здається чіткою: зачекайте місця в пулі потоків, переконайтеся, що служба не була зупинена, а потім зробіть лічильник потоків і встановіть чергу. _runningWorkersдекрементируется всередині SomeMethod()всередині lockзаяву , що тоді дзвінки Monitor.Pulse(_workerLocker).

Моє запитання: чи є якась користь у згрупуванні всього коду в одному lock, наприклад, таким:

        static Object _workerLocker = new object();
        static int _runningWorkers = 0;
        int MaxSimultaneousThreads = 5;

        foreach (int SomeObject in ListOfObjects)
        {
            // Is doing all the work inside a single lock better?
            lock (_workerLocker)
            {
                // wait for room in ThreadPool
                while (_runningWorkers >= MaxSimultaneousThreads) 
                {
                    Monitor.Wait(_workerLocker);
                }
                // check to see if the service has been stopped.
                if (this.IsRunning())
                {
                    ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
                    _runningWorkers++;                  
                }
                else
                {
                    break;
                }
            }
        }

Схоже, це може викликати трохи більше очікування на інші потоки, але тоді, схоже, повторне блокування в одному логічному блоці також буде дещо трудомістким. Однак я новачок у багатопотокових, тому я припускаю, що тут є інші проблеми, про які я не знаю.

Єдине інше місце, де _workerLockerзамикається, - це SomeMethod()лише для того, щоб зменшити декрементацію _runningWorkers, а потім поза межами, foreachщоб дочекатися, коли число _runningWorkersперейде до нуля, перш ніж входити та повертатися.

Дякуємо за будь-яку допомогу.

РЕДАКЦІЯ 4/8/15

Дякуємо @delnan за рекомендацію використовувати семафор. Код стає:

        static int MaxSimultaneousThreads = 5;
        static Semaphore WorkerSem = new Semaphore(MaxSimultaneousThreads, MaxSimultaneousThreads);

        foreach (int SomeObject in ListOfObjects)
        {
            // wait for an available thread
            WorkerSem.WaitOne();

            // check if the service has stopped
            if (this.IsRunning())
            {
                ThreadPool.QueueUserWorkItem(SomeMethod, SomeObject);
            }
            else
            {
                break;
            }
        }

WorkerSem.Release()називається всередині SomeMethod().


1
Якщо весь блок заблокований, як SomeMethod отримає блокування для декрементації _runningWorkers?
Рассел в МСК

@RussellatISC: ThreadPool.QueueUserWorkItem викликає SomeMethodасинхронно, вищезазначений розділ "блокування" буде залишений до або, принаймні, незабаром після SomeMethodзапуску нового потоку .
Док Браун

Влучне зауваження. Наскільки я розумію, що мета Monitor.Wait()- звільнити і знову придбати замок, щоб інший ресурс ( SomeMethodу даному випадку) міг ним користуватися. З іншого боку, SomeMethodотримує замок, зменшує лічильник, а потім викликає, Monitor.Pulse()який повертає замок у відповідний метод. Знову ж таки, це моє власне розуміння.
Йосип

@Doc, пропустив це, але все-таки ... схоже, SomeMethod повинен запуститися до того, як foreach заблокується на наступній ітерації, або він все ще буде підвішений до замка, який утримується "while (_runningWorkers> = MaxSim istovremenoThreads)".
Рассел у МСК

@RussellatISC: як Джозеф вже заявив: Monitor.Waitзвільняє замок. Я рекомендую ознайомитися з документами.
Док Браун

Відповіді:


33

Це не питання ефективності. Це насамперед питання правильності. Якщо у вас є два твердження блокування, ви не можете гарантувати атомічність для операцій, які розповсюджуються між ними, або частково поза заявою блокування. Пристосований до старої версії вашого коду, це означає:

Між кінцем while (_runningWorkers >= MaxSimultaneousThreads)і тим _runningWorkers++, що взагалі може статися все , тому що код здається і знову отримує замок між ними. Наприклад, потік A може придбати замок вперше, зачекайте, поки якийсь інший потік не вийде, а потім вирветься з циклу та lock. Потім його попередньо вибирають, і нитка B виходить на малюнок, також чекаючи місця в пулі ниток. Оскільки згаданий інший потік вийшов, є місце, тому воно зовсім не чекає. І нитка A, і нитка B тепер продовжуються в певному порядку, кожен збільшуючи _runningWorkersі починаючи свою роботу.

На сьогодні, як я бачу, немає перегонів за даними, але логічно це неправильно, оскільки зараз працює більше, ніж MaxSimultaneousThreadsробітників. Перевірка (зрідка) неефективна, оскільки завдання взяти проріз у пулу ниток не є атомним. Це має стосуватися вас більше, ніж невеликих оптимізацій щодо зернистості блокування! (Зверніть увагу, що навпаки, заблокування занадто рано чи занадто довго може легко призвести до тупиків.)

Наскільки я бачу, другий фрагмент вирішує цю проблему. Менш інвазивною зміною для вирішення проблеми може бути введення ++_runningWorkersправоруч після whileогляду, всередині першого твердження про блокування.

Тепер правильність убік, а як щодо продуктивності? Це важко сказати. Зазвичай блокування на більш тривалий час ("грубо") гальмує сумісність, але, як ви кажете, це потрібно збалансувати накладні витрати за рахунок додаткової синхронізації дрібнозернистого блокування. Як правило, єдиним рішенням є тестування та усвідомлення того, що є більше варіантів, ніж "заблокувати все скрізь" та "заблокувати лише найменший мінімум". Існує безліч шаблонів і одночасних примітивів та безпечних для потоків даних даних. Наприклад, це схоже на те, що було придумано саме семафори додатків, тому подумайте про використання одного із тих, замість цього ручного прокатуваного вручну лічильника.


11

ІМХО, ви задаєте неправильне запитання - вам слід не так сильно піклуватися про ефективність, а про правильність.

Перший варіант переконується _runningWorkers, що доступ до нього здійснюється лише під час блокування, але він не вистачає випадку, коли _runningWorkersможе бути змінено інший потік у зазорі між першим замком та другим. Чесно кажучи, на мене виглядає код, якщо хтось наосліп замикав усі точки доступу, _runningWorkersне замислюючись про наслідки та можливі помилки. Можливо, у автора були якісь забобонні страхи щодо виконання breakзаяви у lockблоці, але хто знає?

Отже, ви насправді повинні використовувати другий варіант не тому, що його більш-менш ефективно, а тому, що його (сподіваємось) правильніше, ніж перший.


З іншого боку, утримування блокування під час виконання завдання, яке може зажадати придбання іншого блокування, може спричинити тупик, який навряд чи можна назвати "правильною" поведінкою. Потрібно переконатися, що весь код, який потрібно робити як одиницю, оточений загальним замком, але слід виходити за межі цього блокування речей, які не повинні бути частиною цього блоку, особливо речей, які можуть вимагати придбання інших замків .
supercat

@supercat: це не так, будь ласка, прочитайте коментарі під початковим запитанням.
Док Браун

9

Інші відповіді досить хороші і чітко вирішують питання щодо правильності. Дозвольте мені вирішити ваше більш загальне питання:

Скільки роботи потрібно розмістити всередині оператора блокування?

Почнемо зі стандартної поради, на яку ви натякаєте та делінанте натякаєте на заключний абзац прийнятої відповіді:

  • Робіть якомога менше роботи, блокуючи певний об’єкт. Замки, які тримаються тривалий час, піддаються суперечкам, а суперечки - повільними. Зауважте, що це означає, що загальна кількість коду в конкретному блокуванні та загальна кількість коду у всіх операторах блокування, які блокуються на одному об’єкті, є релевантними.

  • Майте якомога менше замків, щоб знизити ймовірність тупиків (або лайок).

Розумний читач відзначить, що це протилежності. Перший момент пропонує розбити великі замки на багато менших, дрібніших зерен, щоб уникнути суперечок. Другий пропонує консолідувати окремі замки в один і той же об’єкт блокування, щоб уникнути тупикових ситуацій.

Що можна зробити з того, що найкраща стандартна порада є суперечливою? Ми отримуємо насправді гарну пораду:

  • Не їдьте туди в першу чергу. Якщо ви обмінюєтесь пам’яттю між нитками, ви відкриваєтеся для світу болю.

Моя порада: якщо ви хочете паралельність, використовуйте процеси як свою одиницю одночасності. Якщо ви не можете використовувати процеси, тоді використовуйте домени додатків. Якщо ви не можете використовувати домени прикладних програм, то керуйте своїми потоками в бібліотеці паралельних завдань і запишіть свій код у частині завдань високого рівня (завдань), а не потоків низького рівня (робітників).

Якщо ви абсолютно позитивно повинні використовувати примітиви низького рівня одночасності, як нитки або семафори, то використовуйте їх для створення абстракції вищого рівня, яка фіксує те, що вам справді потрібно. Ви, ймовірно, виявите, що абстракція вищого рівня - це щось на кшталт "виконайте завдання асинхронно, яке може бути скасовано користувачем", і так, TPL це вже підтримує, тому вам не потрібно катати свою власну. Ви, швидше за все, виявите, що вам потрібно щось на зразок безпечної ініціалізації, пов'язаної з потоком; не скочуйте своє, використовуйте Lazy<T>, що було написано експертами. Використовуйте безпечні колекції (незмінні чи іншими), написані експертами. Перемістіть рівень абстракції на максимально високий рівень.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.