4

Details:

I have a game with two independent AIs playing agains each other. Each AI has its own task. Both tasks need to start at the same time, need to take some parameters and return a value. Now I want to run 100-200 games (with each two tasks) in parallel.

The problem that I now have is that the two tasks are not started together. They are started completely random, whenever there are some free resources.

Code:

My current approach is like following.

  • I have a list of inputobjects which include some of the parameter.
  • With Parallel.ForEach I create for each inputobject a game and two AIs for the game.
  • Whichever AI finishes the game first stops the other AI, playing the same game, with a CancellationToken.
  • All returned values are saved in a ConcurrentBag.

Because with just that the two AI-Tasks for each game are not started together, I added an AutoResetEvent. I hoped that I could wait with one task until the second task has started but instead the AutoResetEvent.WaitOne blocks all resources. So the result with AutoResetEvent is that the first AI-Tasks are starting and waiting for the second task to start, but since they do not free the threads again they wait forever.

        private ConcurrentBag<Individual> TrainKis(List<Individual> population) {
            ConcurrentBag<Individual> resultCollection = new ConcurrentBag<Individual>();
            ConcurrentBag<Individual> referenceCollection = new ConcurrentBag<Individual>();

            Parallel.ForEach(population, individual =>
            {
                GameManager gm = new GameManager();

                CancellationTokenSource c = new CancellationTokenSource();
                CancellationToken token = c.Token;
                AutoResetEvent waitHandle = new AutoResetEvent(false);

                KI_base eaKI = new KI_Stupid(gm, individual.number, "KI-" + individual.number, Color.FromArgb(255, 255, 255));
                KI_base referenceKI = new KI_Stupid(gm, 999, "REF-" + individual.number, Color.FromArgb(0, 0, 0));
                Individual referenceIndividual = CreateIndividual(individual.number, 400, 2000);

                var t1 = referenceKI.Start(token, waitHandle, referenceIndividual).ContinueWith(taskInfo => {
                    c.Cancel();
                    return taskInfo.Result;
                }).Result;
                var t2 = eaKI.Start(token, waitHandle, individual).ContinueWith(taskInfo => { 
                    c.Cancel(); 
                    return taskInfo.Result; 
                }).Result;

                referenceCollection.Add(t1);
                resultCollection.Add(t2);
            });

            return resultCollection;
        }

This is the start method of the AI where I wait for the second AI to play:

            public Task<Individual> Start(CancellationToken _ct, AutoResetEvent _are, Individual _i) {
                i = _i;
                gm.game.kis.Add(this);
                if (gm.game.kis.Count > 1) {
                    _are.Set();
                    return Task.Run(() => Play(_ct));
                }
                else {
                    _are.WaitOne();
                    return Task.Run(() => Play(_ct));
                }
            }

And the simplified play method

public override Individual Play(CancellationToken ct) {
            Console.WriteLine($"{player.username} started.");
            while (Constants.TOWN_NUMBER*0.8 > player.towns.Count || player.towns.Count == 0) {
                try {
                    Thread.Sleep((int)(Constants.TOWN_GROTH_SECONDS * 1000 + 10));
                }
                catch (Exception _ex) {
                    Console.WriteLine($"{player.username} error: {_ex}");
                }
                
                //here are the actions of the AI (I removed them for better overview)

                if (ct.IsCancellationRequested) {
                    return i;
                }
            }
            if (Constants.TOWN_NUMBER * 0.8 <= player.towns.Count) {
                winner = true;
                return i;
            }
            return i;
        }

Is there a better way of doing this, keeping all things but ensure that the two KI-Tasks in each game are started at the same time?

13
  • How do the two AI players interact with each other? Do they read and write to some shared state? Are these read/write operations synchronized using a lock or other synchronization primitive, or they are lock-less? Commented Jan 1, 2021 at 20:07
  • Both AIs interact with the same game manager. Some parts (like the quadtree which contains the current state of the game) of the game manager are locked to prevent errors. Commented Jan 1, 2021 at 20:25
  • Is it an option to use coroutines instead of Tasks to coordinate each pair of AI players? The idea is to expose each AI player as an iterator (a method that returns IEnumerable and contains yield statements) instead of Task, and have a single Task for each game that "unwinds" both iterators one step at a time. Commented Jan 1, 2021 at 20:34
  • Would the two players then always take turns making a move? So KI1 does an aktion and then KI2, KI1... and so on? Both KIs need to play completely free... Commented Jan 1, 2021 at 20:39
  • 1
    Oh, I see. Could you include in your question a simplified example of the asynchronous Start method of an AI player, so that we are able to offer alternative suggestions? (instead of coroutines) Commented Jan 1, 2021 at 20:57

1 Answer 1

3

My suggestion is to change the signature of the Play method so that it returns a Task<Individual> instead of Individual, and replace the calls to Thread.Sleep with await Task.Delay. This small change should have a significant positive effect to the responsiveness of the AI players, because no threads will be blocked by them, and the small pool of ThreadPool threads will be optimally utilized.

public override async Task<Individual> Play(CancellationToken ct)
{
    Console.WriteLine($"{player.username} started.");
    while (Constants.TOWN_NUMBER * 0.8 > player.towns.Count || player.towns.Count == 0)
    {
        //...
        await Task.Delay((int)(Constants.TOWN_GROTH_SECONDS * 1000 + 10));
        //...
    }
}

You could also consider changing the name of the method from Play to PlayAsync, to comply with the guidelines.

Then you should scrape the Parallel.ForEach method, since it is not async-friendly, and instead project each individual to a Task, bundle all tasks in an array, and wait them all to complete with the Task.WaitAll method (or with the await Task.WhenAll if you want to go async-all-the-way).

Task[] tasks = population.Select(async individual =>
{
    GameManager gm = new GameManager();
    CancellationTokenSource c = new CancellationTokenSource();

    //...

    var task1 = referenceKI.Start(token, waitHandle, referenceIndividual);
    var task2 = eaKI.Start(token, waitHandle, individual);

    await Task.WhenAny(task1, task2);
    c.Cancel();
    await Task.WhenAll(task1, task2);
    var result1 = await task1;
    var result2 = await task2;
    referenceCollection.Add(result1);
    resultCollection.Add(result2);
}).ToArray();

Task.WaitAll(tasks);

This way you'll have maximum concurrency, which may not be ideal, because the CPU or the RAM or the CPU <=> RAM bandwidth of your machine may become saturated. You can look here for ways of limiting the amount of concurrent asynchronous operations.

Sign up to request clarification or add additional context in comments.

3 Comments

@theoretisch in short, locate all .Wait() and .Result invocations anywhere in your program, and eliminate as many of them as you can (ideally all of them). In most cases these are harmful for the responsiveness or scalability of a program.
Just implemented it and it works perfectly. Even with 200 and more games at the same time. Thank you so much. All the links are also very helpful!
@theoretisch the quick and dirty fix would be to increase the number of threads that the ThreadPool creates instantly on demand, by calling ThreadPool.SetMinThreads(1000, 1000) at the start of your program. Although this would probably fix your problem, it would be quite wasteful because each thread allocates 1 MB of RAM by default, just for its stack. So with, say, ~400 created threads you would have ~400MB of wasted RAM, dedicated to threads that would spend most of their time sleeping. :-)

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.