Эксклюзивный замок против нити волокна

В настоящее время я работаю над приложением С#, которое будет работать на стороне сервера многопользовательской игры, и я немного не уверен, как мне следует справляться с проблемами многопоточности. Прежде чем я продолжу, вероятно, стоит упомянуть, что я новичок в этой теме.

Проблема

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

Решение проблемы

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

Недавно, после некоторых исследований по этой теме, я познакомился с идеей волокон. , а также способ настройки «пула волокон», позволяющий ставить в очередь действия на одном волокне, что является еще одним способом обеспечения того, чтобы запросы выполнялись в последовательном порядке.

Вопрос

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

Любая помощь будет принята с благодарностью.


person Donny Sutherland    schedule 23.09.2015    source источник
comment
Здесь есть несколько интересных дискуссий о волокнах, посвященных переполнению стека, попробуйте поискать С#, используя волокна, которые они отправляют в пару статей MSDN, которые, вероятно, могут помочь.   -  person Sabrina_cs    schedule 23.09.2015
comment
@Sabrina_cs - спасибо за вклад. Мне до сих пор нравилось читать некоторые обсуждения на эту тему, и, вероятно, я продолжу это делать. То, с чем я борюсь в данный момент, — это четкое сравнение между ними. Еще раз спасибо за вклад.   -  person Donny Sutherland    schedule 23.09.2015
comment
Волокна — это сложная оптимизация, которая ничего не делает для решения вашей задачи, связанной с обработкой общих данных. Один гигантский замок — плохое решение, потому что он не масштабируется, поэтому вам понадобится более тонкая блокировка и все, что с этим связано.   -  person Voo    schedule 23.09.2015
comment
@Voo - спасибо за вклад. Таким образом, ваше предложение будет состоять в том, чтобы придерживаться блокировки и просто быть умным в этом? Мне приятно это слышать   -  person Donny Sutherland    schedule 23.09.2015
comment
Что ж, я бы посоветовал получить хорошую книгу о параллелизме, прежде чем даже думать о попытке найти работающее решение. Это достаточно сложная тема, чтобы не заходить в самые темные уголки.   -  person Voo    schedule 23.09.2015
comment
@Voo - звучит как план. Еще раз спасибо за вклад!   -  person Donny Sutherland    schedule 23.09.2015
comment
Давным-давно я разработал несколько небольших решений, где мне нужна была многопоточность, и у меня была проблема с блокировкой на меньшее возможное время. Я решил использовать блокировку и очередь, чтобы быть уверенным, что все потоки могут как можно быстрее добавлять задания в очередь, а многопоточный процессор заданий заблокировал очередь только на время выполнения задания, чтобы ни один другой поток не пытался принимать одновременно. Затем трудоемкая операция управлялась конкретным потоком без проблем с пересечением. Я не знаю, ваш ли это случай, но просто чтобы дать вам некоторые идеи   -  person Sabrina_cs    schedule 25.09.2015
comment
@Sabrina_cs. Насколько я понимаю, мы блокируем, чтобы ограничить доступ к критическим разделам кода одним потоком с конечной целью решения проблем, когда несколько потоков одновременно обращаются к одному и тому же ресурсу. Похоже, мы на одной странице об этом. Ваше добавление очереди звучит очень интересно и связано с тем, что Джордж сказал об асинхронности для уменьшения конкуренции. Это определенно то, что я очень хочу попробовать. Еще раз спасибо за полезный вклад.   -  person Donny Sutherland    schedule 25.09.2015


Ответы (1)


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

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

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

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

Уменьшить конкуренцию

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

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

using System;
using System.Threading;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        var game = new SortingGame();
        var random = new Random(234);

        // Simulate few concurrent players.
        for (var i = 0; i < 3; i++)
        {
            ThreadPool.QueueUserWorkItem(o =>
            {
                while (!game.IsSorted())
                {
                    var x = random.Next(game.Count() - 1);
                    game.PlayAt(x);
                    DumpGame(game);
                };
            });
        }

        Thread.Sleep(4000);

        DumpGame(game); 
    }

    static void DumpGame(SortingGame game)
    {
        var items = game.GetBoardSnapshot();

        Console.WriteLine(string.Join(",", items));
    }
}


class SortingGame
{
    List<int> items;
    List<object> lockers;

    // this lock is taken for the entire board to guard from inconsistent reads.
    object entireBoardLock = new object();

    public SortingGame()
    {
        const int N = 10;

        // Initialize a game with items in random order
        var random = new Random(1235678);
        var setup = Enumerable.Range(0, N).Select(i => new { x = i, position = random.Next(0, 100)}).ToList();
        items = setup.OrderBy(i => i.position).Select(i => i.x).ToList();
        lockers = Enumerable.Range(0, N).Select(i => new object()).ToList();
    }

    public int Count()
    {
        return items.Count;
    }

    public bool IsSorted()
    {
        var currentBoard = GetBoardSnapshot();
        var pairs = currentBoard.Zip(currentBoard.Skip(1), (a, b) => new { a, b});
        return pairs.All(p => p.a <= p.b);
    }

    public IEnumerable<int> GetBoardSnapshot()
    {
        lock (entireBoardLock)
            return new List<int>(items);
    }

    public void PlayAt(int x)
    {
        // Find the resource lockers for the two adjacent cells in question
        var locker1 = GetLockForCell(x);
        var locker2 = GetLockForCell(x + 1);

        // It's important to lock the resources in a particular order, same for all the contending writers and readers.
        // These can last for a long time, but are granular,
        // so the contention is greatly reduced.
        // Try to remove one of the following locks, and notice the duplicate items in the result
        lock (locker1)
        lock (locker2)
            {
                var a = items[x];
                var b = items[x + 1];
                if (a > b)
                {
                    // Simulate expensive computation
                    Thread.Sleep(100);
                    // Following is a lock to protect from incorrect game state read
                    // The lock lasts for a very short time.
                    lock (entireBoardLock)
                    {
                        items[x] = b;
                        items[x + 1] = a;
                    }
                }           
            }
    }

    object GetLockForCell(int x)
    {
        return lockers[x];
    }
}

Устранение повторяющихся вычислений

Если вам нужны какие-то дорогостоящие вычисления, чтобы быть актуальными, но не зависящими от конкретного запроса, попытка вычислить их для каждого запроса будет просто пустой тратой ресурсов.

Следующий подход позволяет пропустить повторные вычисления, если вычисления уже были начаты для другого запроса.

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

void Main()
{
    for (var i = 0; i < 100; i++)
    {
        Thread.Sleep(100);
        var j = i;
        ThreadPool.QueueUserWorkItem((o) => {
            // In this example, the call is blocking becase of the Result property access.
            // In a real async method you would be awaiting the result.
            var result = computation.Get().Result;

            Console.WriteLine("{0} {1}", j, result);
        });
    }
}

static ParticularSharedComputation computation = new ParticularSharedComputation();

abstract class SharedComputation
{
    volatile Task<string> currentWork;
    object resourceLock = new object();
    public async Task<string> Get()
    {
        Task<string> current;
        // We are taking a lock here, but all the operations inside a lock are instant.
        // Actually we are just scheduling a task to run.
        lock (resourceLock)
        {
            if (currentWork == null)
            {
                Console.WriteLine("Looks like we have to do the job...");
                currentWork = Compute();
                currentWork.ContinueWith(t => {
                    lock (resourceLock)
                        currentWork = null;
                });
            }
            else
                Console.WriteLine("Someone is already computing. Ok, will wait a bit...");
            current = currentWork;
        }

        return await current;
    }

    protected abstract Task<string> Compute();
}

class ParticularSharedComputation : SharedComputation
{
    protected override async Task<string> Compute()
    {
        // This method is thread safe if it accesses only it's instance data,
        // as the base class allows only one simultaneous entrance for each instance.
        // Here you can safely access any data, local for the instance of this class.
        Console.WriteLine("Computing...");

        // Simulate a long computation.
        await Task.Delay(2000);

        Console.WriteLine("Computed.");
        return DateTime.Now.ToString();
    }
}

Используйте асинхронный режим, а не только многопоточность

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

Хорошо спроектированное асинхронное приложение будет фактически использовать столько потоков, сколько ядер ЦП в вашей системе.

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

person George Polevoy    schedule 23.09.2015
comment
Мне очень нравится этот ответ. Это почти все прояснило для меня. Ваши примеры элегантны и информативны. Спасибо, что нашли время, чтобы сделать это. - person Donny Sutherland; 23.09.2015