Share a project between threads#

Run work on one solved project from several threads at once, such as trying different edits in parallel, without corrupting it.

A Project isn't thread-safe. Even calls that look read-only write to it: Clone() sets copy-on-write flags on the source. So give each thread its own clone, and make all the clones on one thread before the workers start. Clone() is cheap. A clone shares the data with its source until one of them is edited, and the first edit copies it.

Prerequisites#

Steps#

  1. Solve the project on the thread that owns it, and keep the result:

    var project = new Project();
    var plan = project.Solve(data, new SolveOptions());
    
  2. On the same thread, make one clone per worker:

    var drafts = variants.Select(_ => project.Clone()).ToArray();
    
  3. Start the workers and give each one its own clone. A worker can call anything on its clone: Apply, Solve, Preview, Level. It must not touch project or another worker's clone:

    Parallel.For(0, variants.Length, i =>
    {
        var draft = drafts[i];
        var changes = new SchedulingChanges { BaselineId = plan.BaselineId };
        changes.Tasks.Update.Add(new TaskUpdate("design") { Duration = variants[i] });
        draft.Apply(changes);
        var result = draft.Solve(null, new SolveOptions());
        // ...
    });
    

    Every clone starts with the source's BaselineId, so plan.BaselineId passes the stale check on each of them. After an edit, each clone mints its own token.

  4. Treat every result as read-only. A clone that hasn't changed returns the same cached ScheduleResult object as its source, so several threads can end up reading plan at once. Call DeepClone() on a result before you modify it.

  5. Keep the variant the user picks: project = drafts[i];. Do this on the owning thread after the workers finish. If project was edited after you cloned it, the drafts started from an older state, so clone again.

To share one instance instead of cloning, put every call on it, including Clone(), inside the same lock:

var gate = new object();

lock (gate)
{
    project.Apply(changes);
    project.Solve(null, new SolveOptions());
}

The calls then run one at a time. Clones run in parallel, so use them when the threads need throughput.

The full example tries three durations for a task on three threads and prints when the next task ends:

using Plen;

// Monday, August 10, 2026, on the default calendar.
var monday = Moment.FromYmdHm(2026, 8, 10, 8, 0) ?? throw new InvalidOperationException("invalid date");

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("design") { StartDate = monday, Duration = 2.0 },
        new TaskData("build") { StartDate = monday, Duration = 3.0 },
    ],
    Links = [new LinkData { Id = "l1", Source = "design", Target = "build" }],
};

var project = new Project();
var plan = project.Solve(data, new SolveOptions());

// Durations of "design" to try, one per worker.
double[] variants = [3.0, 4.0, 5.0];

// Make every clone here, on the owning thread.
var drafts = variants.Select(_ => project.Clone()).ToArray();

var builds = new Mutation?[variants.Length];
Parallel.For(0, variants.Length, i =>
{
    var draft = drafts[i];
    var changes = new SchedulingChanges { BaselineId = plan.BaselineId };
    changes.Tasks.Update.Add(new TaskUpdate("design") { Duration = variants[i] });
    var report = draft.Apply(changes);
    if (report.Rejected.Count != 0)
    {
        throw new InvalidOperationException(report.Rejected[0].Message);
    }
    var result = draft.Solve(null, new SolveOptions());
    builds[i] = result.Updates.FirstOrDefault(u => u.Id == "build");
});

for (var i = 0; i < variants.Length; i++)
{
    Console.WriteLine($"design {variants[i]} days: build ends {builds[i]?.EndDate}");
}

var original = project.Solve(null, new SolveOptions());
Console.WriteLine($"original baseline kept: {original.BaselineId == plan.BaselineId}");

Result#

Each thread reports its own variant, and the original project is untouched:

design 3 days: build ends 2026-08-18 08:00
design 4 days: build ends 2026-08-19 08:00
design 5 days: build ends 2026-08-20 08:00
original baseline kept: True

Parallel.For throws an AggregateException if a worker throws, for example an InputException from Solve.

To score alternative change sets in parallel, you don't need your own threads. EvaluateWith(candidates, options, Execution.Parallel()) clones and scores the candidates on worker threads and returns the results in input order. See Evaluating alternatives. To keep long calls off a request thread in async code, see Call from async code. For how clones behave, see Cloning a project.