I have a job system that has two different types of dependencies per-job:
- Jobs can rely on other jobs to be completed first, but this is optional
- Jobs have read/write dependencies (shared/exclusive) that are used to restrict which jobs run at the same time. This is currently represented as a series of type hashes for each read-only and each writable C++ type
The C++-flavoured psuedo-code shows the algorithm being used. My main issue is that when we want to check if a job can run (after its optional dependent jobs have completed), we need to lock around the list of currently in-use dependencies, and again when a job finishes so that job can remove its own dependencies from the active list, unblocking other jobs.
I'm wondering if it's possible to make this atomic; like representing each dependency type as a bit and trying to hit it in one instruction, but with the mix of shared/exclusive types, I don't know how you can represent that without a mix of different bitwise ops.
OnJobFinished(job)
{
lock(dependenciesMtx)
PopJobDependencies(job) // Clear this job's shared/exclusive dependencies
unlock(dependenciesMtx)
for connection in job.connections
connection.numBlocking--;
if connection.numBlocking == 0
contendingQueue.push(connection)
ProcessContendingJobs()
}
Job.Process()
{
do actual job work
// ...
OnJobFinished(this)
}
LaunchJob(job)
{
Launch job fiber -> job.Process(...) // this puts job.Process on another thread/fiber and is non-blocking
}
bool CanRunNode(job)
{
if job.exclusiveDeps in (currentDeps.shared + currentDeps.exclusive)
return false
if job.sharedDeps in currentDeps.exclusive
return false
return true
}
ProcessContendingJobs()
{
while (job = contendingQueue.pop())
lock(dependenciesMtx)
if CanRunNode(job)
PushJobDependencies(job) // Store this job's shared/exclusive dependencies
LaunchJob(job)
else
jobsToRepush.push(job)
unlock(dependenciesMtx)
// we couldn't execute them this time, so re-add to queue for later
for job in jobsToRepush
contendingQueue.push(job)
}
void ProcessJobs(jobs) <<<<< Start here
{
// Jobs have optional explicit dependencies on other jobs (job A -> 'connections' -> job B),
// and the dependee knows how many jobs are blocking it from executing.
// They also have dependencies on specific types (as shared deps [read] and exclusive deps [write])
// contendingQueue is a concurrent queue (via Intel TBB)
// Get at least some work in the queue before we start
for job in jobs
if job.numBlocking == 0
contendingQueue.push(job)
ProcessContendingJobs()
//...
// Wait for all work to finish
}
There are a number of issues here;
- The contention on the dependencies mutex, as stated
- We have to pop a job from the queue to check if that job can run, so it's often the case that it can't be and we have to re-push it. This is a potential source of churn and is a waste of work
- Should a queue processor in this context push the job right back into the queue it's looping over, potentially looping constantly until a runnable job is found? Also seems wasteful.
EDIT: Some ideas I thought of (though unrelated to current answer and won't change it):
- Switch to a read-write lock, where the dependency check acquires only a read lock, and if the job can run, we upgrade to a write lock and push its dependencies. Bit tricky since the fiber library I use has no shared_mutex equivalent, but can be written.
- Keep the lock in OnJobFinished locked and call a modified ProcessContendingJobs that expects the lock to already be acquired and simply unlocks after the while loop completes. Less-frequent lock/unlock but holds the lock for longer.
- Similarly, if not the above, then change the while loop to keep a lock before iteration and unlock after the loop is finished. Similar issue to the above; not sure if it's better to reduce number of lock/unlock calls at the expense of making the lock duration longer.
if (check()) { lock(); if (check()) doTheThing(); unlock(); }
. (2) This sounds sensible, but you don't have to modify ProcessContendingJobs if you're using a reentrant lock. (3) At some point it's also worth considering an architecture where there's a single thread that performs scheduling and dependency management, and other threads just notify this scheduler if jobs have completed. The critical sections that still need locks would then only involve queue operations, not dependency management.