awkward-squad / ki

A structured concurrency library

Home Page:https://hackage.haskell.org/package/ki

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

How to kill a single thread

TristanCacqueray opened this issue · comments

Would it be possible to add a kill :: Thread a -> IO () function? And could this be ignored by the thread scope?

#7 mention being able to cancel a thread, is there a reason not to?

I do think are some reasons not to provide killThread but I'm happy to hear out your use case to see if the rationale still holds.

You can currently only kill threads only as a bundle, by falling through the bottom of the associated scoped call.

By omitting a killThread in the API:

  • There is only one way to kill a thread, not two.
  • A thread always either runs to completion, throws a synchronous exception, or is killed by an asynchronous exception, which is very likely to be due to this library closing its scope (but other possible exceptions include out-of-memory thrown by the RTS).

A benefit of this design is you don't have to worry that a thread which is supposed to run in the background forever, or otherwise has some expectation of being alive at a certain time, has not been silently killed prior by some other thread.

My use-case would be to implement a supervisor thread that can cancel other threads dynamically, for example in a game server to terminate miss behaving client. I guess with the current API, such logic can be implemented by wrapping the thread action with a custom exception handler. But I was wondering if that could be done more easily by exposing a killThread function.

An alternate design here might be to have each client run like:

scoped \s -> do
  fork_ do
    takeMVar stop >>= throwIO Terminated
  runClient

Now your supervisor can be given MVars and put into then to kill clients. It does mean each client now has another fork though.

I realize that if a thread can be terminated, then we also need a way to check if a thread is still running. The current API does not seems to allow that, e.g. it is missing something like tryAwait :: Thread a -> STM (Maybe a). Well I'm still exploring how concurrency works, so I don't know if any of this makes sense, but for the purpose of this discussion, here is my current solution:

import Control.Exception (SomeException, Exception, fromException)
import Control.Concurrent (ThreadId, myThreadId, throwTo)
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TMVar
import Ki qualified

data Status a = Running ThreadId | Killed | Died SomeException | Completed a deriving Show
newtype BackgroundThread a = BackgroundThread (TMVar (Status a))

backgroundThread :: Ki.Scope -> IO a -> IO (BackgroundThread a)
backgroundThread scope action = do
  status <- newEmptyTMVarIO
  child <- Ki.forkTry scope $ do
    -- register the action ThreadId
    threadId <- myThreadId
    atomically $ putTMVar status (Running threadId)
    action
  Ki.fork scope $ atomically $ do
    -- thread supervisor
    res <- Ki.await child
    swapTMVar status $ case res of
      Right result -> Completed result
      Left err -> case fromException err of
        Just KillThread -> Killed
        Nothing -> Died err
  pure $ BackgroundThread status

data KillException = KillThread deriving Show
instance Exception KillException

killThread :: BackgroundThread a -> IO ()
killThread (BackgroundThread status) = do
  s <- atomically $ readTMVar status
  case s of
    Running threadId -> throwTo threadId KillThread
    _ -> fail "Can't kill non-running thread"

Like @ocharles solution, this cost an extra fork, and it doesn't seems like this is breaking the structured concurrency guarantees. Assuming this is correct, I guess it's fine to implement such capability on top of the ki's API. Though I wonder if it would possible to have this as part of the ki's Core API, so that we don't need the extra fork.

I like @ocharles solution. You probably wouldn't want the thread you ask to die also throw an exception back, though. That might look like:

client :: TMVar () -> IO a -> IO (Maybe a)
client doneVar action =
  scoped \scope -> do
    thread <- fork scope action
    atomically $
      (do
        () <- readTMVar doneVar
        pure Nothing)
      <|>
      (do
        result <- await thread
        pure (Just result))

(whew that was hard to type on mobile; may not compile).

As far a whether it would be worth adding killThread to the API to avoid having to spawn a second thread to act as the hitman, I'm still not sure, but leaning towards no. And as always, happy to be convinced any which way.

Alright thank you so much for the feedback. The client solution does compile and works as expected! Thus I agree and it does not seems worth adding killThread to the API.

For what its worth, I'm working on a ki-effectful library, and I'm testing this client solution here: https://github.com/TristanCacqueray/ki-effectful/blob/main/test/Main.hs#L46-L62

I would personally lean towards leaving killThread out. @snoyberg had good advice on Twitter which is that whenever you can avoid relying on asynchronous exceptions, you should. In this case, there is definitely a solution without using async exceptions.

Thanks again for the suggestions, it seems like they solve that issue. Please re-open otherwise.

No problem, I'm sure others will have the same question, I think I'll make this into a "discussion" and see if that helps any.