pht icon indicating copy to clipboard operation
pht copied to clipboard

Plexing needed to help with production readiness...

Open joeyhub opened this issue 5 years ago • 6 comments

For large scale use and production readiness however, it really wants plexing. You sort of achieve that with the mutxes, but only within a limited scope.

If I want to use this for battle ready scenarios and large scale, there are areas where it can block where it doesn't have to leading to things like hanging, lost performance gains, etc.

My particular use case is that I want to use this buried between layers of abstraction that let you program asyncronously but very easily. The result of that can be a surprising amount of different scenarios and consider and use cases and need to be handled out of site.

I think a really quick fix might be something like:

Thread {
    public static multiJoin(int $timeout, Thread ...$threads):?Thread
}

Though that still leaves a lot of questions unanswered. I may want to wait for joins and locks at the same time. Same issue with the mutexes, you might want to wait for the first to unlock. You don't really want things like interrupted exceptions everywhere either.

You could consider a Selectable interface for anything blocking (locks, join, etc).

If you consider stability, there's a lot to think about such as what happens when one thing putting on the queue dies?

The mutexes do have a limitation over sync. I can't see an obvious way to wait for data efficiently. In thread communication there is a need to syncronise as well as exchange data safely. In this case I think only the latter case is really directly supported.

Unless I'm not thinking straight it requires some odd roundabout ways to do some things.

$q = new Queue();

function producer() use($q) {
    for($i = 0; $i < 10**6; $i++) {
        $q->lock();
        $q->push($i);
        $q->unlock();
    }
}

function consumer() use($q) {
    while(true) {
        $q->lock();
        $items = $q->shiftAll();
        $q->unlock();
        foreach($items as $i)
            echo "$i\n";
    }
}

thread producer();
thread consumer();

It's subtle but if you look closely you'll notice that the consumer will use 100% CPU, doing nothing while the producer is doing something other than working on the queue. The obvious way to see that is to comment out starting the producer and watch the CPU go whoooop.

$q = new Queue();
// Wrong thread lock ownership!
$q->lock();

function producer() use($q) {
    for($i = 0; $i < 10**6; $i++) {
        $q->push($i);
        $q->unlock();
        if(rand(0, 10**5) === 0)
            throw new Exception();
        $q->lock();
    }

    $q->unlock();
}

$active = true;

function consumer() use($q, &$active) {
    // When does it end?
    while(true) {
        $q->lock();
        $items = $q->shiftAll();
        if(!$active && count($items) === 0)
            break;
        $q->unlock();
        foreach($items as $i)
            echo "$i\n";
    }
}

$c = thread consumer();
join thread producer();
$active = false;
join $c;

I'm not really sure if there are any data exchange cases mutexes can't handle and at this point I'll skip the cognitive effort of determining that as at this point I think there's already enough to generate questions and to give food for thought. This is also just based on a very quick glance of the library so I might have tripped up on something somewhere.

I believe you can use the mutex to achieve sync but really that implies pretty much that you actually want standalone mutexs. Appart from being ugly the main limitation seems to be the lack of a select on locks as well as the question if a thread can unlock someone else's lock.

joeyhub avatar Feb 17 '19 23:02 joeyhub

Even pthread mutexes do have a syncronisation mechanism it turns out which you've not exposed, if you look at its signalling.

joeyhub avatar Feb 18 '19 12:02 joeyhub

Example of something I'm up to on my fork:

// Note: Bool might not be enough.
zend_bool handle_thread_tasks(thread_obj_t *thread) {
	while(1) {
		pthread_mutex_lock(&thread->lock);

		if(thread->status == JOINED) {
			pthread_mutex_unlock(&thread->lock);
			return TRUE;
		}

		if(!thread->tasks->size && !pthread_cond_wait(&thread->has_tasks, &thread->lock)) {
			pthread_mutex_unlock(&thread->lock);
			return FALSE;
		}

		task_t *task = pht_queue_pop(&thread->tasks);
		pthread_mutex_unlock(&thread->lock);

		if (!task) {
			return FALSE;
		}

		switch (task->type) {
			case CLASS_TASK:
				handle_class_task(task);
				break;
			case FUNCTION_TASK:
				handle_function_task(task);
				break;
			case FILE_TASK:
				handle_file_task(task);
				break;
		}

		task_delete(task);
	}

	return TRUE;
}

If you compare that to the original that just locks and unlocks around the queue...

I'm not actually sure this is going to be enough as thread state and queue state are two different things though I've opted to leave that for later (ignore obvious issues like returning from joined before consuming queue, returning after loop with no break, etc).

What this means is that if the queue is empty, it waits until it's not. If you don't have that, the loop just goes round and round for ever, never blocking or waiting, just eating CPU cycles.

The mutex is not guaranteed to be locked when it tries to lock it. If it's not locked when it tries to lock it, it just returns immediately. So while it's not locked by something else, or while there's no contention this loop will just spin and spin, almost becoming a spin lock :D.

joeyhub avatar Feb 18 '19 15:02 joeyhub

Wait a minute.

while (thread->status != JOINED || thread->tasks.size) {

How do you still run tasks when the thread has gone? Something there I'm missing (have only looked at a small bit of the implementation).

joeyhub avatar Feb 18 '19 16:02 joeyhub

How do you still run tasks when the thread has gone?

You don't. When you join a thread, any tasks associated with that thread should be considered finished with (if they aren't finished with, then why are you joining the thread?).

I'll look through this more over this weekend (I'm too busy on week days to do this right now, unfortunately).

tpunt avatar Feb 18 '19 21:02 tpunt

No rush, it's not a set of issues that really can be quick fixed.

joeyhub avatar Feb 18 '19 22:02 joeyhub

Just to make it less ambiguous. The tasks thing is a bit of a red herring, you're using that status to tell the thread if it should shut down or not, not to say that a join has finished but something wants it to finish (has joined into it).

There are two issues here: too much blocking in one place, not enough blocking in another place

Too much

Too much blocking is when calling join. Basically people want a notification of when a thread is finished. This can be done in userland to some degree but it's not entirely reliable and can waste a small amount of time blocking unnecessarily. I can only join one thread at a time. If I have two threads, the first one I join could take ages leaving the thread wasting time waiting for that one when it could be doing stuff after the second one finished.

This is actually related to a limitation in POSIX threads. Doing some research I'm not the only one complaining about that. However it's not necessarily a limitation you have to export when wrapping pthreads in a higher level wrapper. You can at least have thread detach and have each thread when they're about to exit send a signal. This means you'd want to also make sure you avoid any situation where a thread can get lost or not exit gracefully.

This should be quite reliable much of the time and minimise excess blocking though there's always the question of is it possible for the thread to fail in some other manner (an occasional sweep up might make sense). I'd assume things like OOM, segfault, etc will down out a whole process. Leaves me wondering under what condition a thread might unexpectedly pop off and if it can be caused by programming error.

Not enough

It's not enough to just wrap a mutex around shared data. Threads need to wait (block) until that data enters certain states.

This situation is already well explained but a simple plain English explaination...

If you have one thread that pops from a stack and a thread that pushes to the stack, the thread that pops from the stack should go to sleep until there's something on the stack, so when the thread pushing to the stack does so, it needs to wake up the thread popping from the stack. The lock doesn't achieve that, all it does is to prevent them accessing the stack at the same time. The result is that the thread popping from the stack almost never sleeps, it just keeps checking if the stack is not empty over and over which wastes a lot of CPU.

joeyhub avatar Feb 20 '19 03:02 joeyhub