The darker corners of throwTo
Posted on June 11, 2013We will assume
import System.IO.Unsafe (unsafePerformIO, unsafeInterleaveIO)
import System.IO.Error (catchIOError)
import Control.Concurrent (throwTo, myThreadId, threadDelay)
import Control.Concurrent.Async (async, cancel, wait)
import qualified Control.Exception as Ex
in the remainder of this post.
Whatever work the target thread was doing when the exception was raised is not lost: the computation is suspended until required by another thread.
We can observe this directly when using unsafePerformIO :
fib :: Int -> Int
0 = 1
fib 1 = 1
fib = fib (n - 1) + fib (n - 2)
fib n
fooIO :: IO Int
= go 0 37
fooIO where
!acc 30 = return acc
go !acc n = print n >> go (acc + fib n) (n - 1)
go
test :: Int -> IO ()
= do
test foo <- async $ print foo
a1 500000
threadDelay <- async $ print foo
a2 4000000
threadDelay putStrLn "Killing a1"
cancel a1
wait a2
main :: IO ()
= test $ unsafePerformIO fooIO main
The test starts by spawning a new thread that begins summing the 37th down to the 31st Fibonacci number by demanding the value of the foo = unsafePerformIO fooIO
thunk. After half a second a second thread is spawned which blocks on the same thunk, and will wait for the first thread to finish. Consider, however, what happens when we then kill the first thread (the cancel function from async throws an asynchronous ThreadKilled exception to the destination thread): the second thread seamlessly takes over the computation of the thunk. Compiling the above example (without any ghc options) and running it gives
37
36
Killing a1
35
34
33
32
31
98809577
Nice. But now consider what happens when we replace fooIO by
fooIO_1 :: IO Int
= fooIO `catchIOError` \_ -> return 0 fooIO_1
Since no IO exception is ever thrown, you would think that this should make no difference right? Wrong:
37
36
Killing a1
ExTest: thread killed
What happened? catchIOError
actually catches all exceptions, not just IOErrors
; however, it then re-throws all other exceptions. fooIO_1
is actually equivalent to
fooIO_2 :: IO Int
= fooIO `Ex.catch` \e ->
fooIO_2 case Ex.fromException (e :: Ex.SomeException) of
Just (ioerror :: IOError) -> return 0
Nothing -> Ex.throwIO e
The asynchronous exception thrown by cancel is caught and then rethrown as a synchronous exception. This synchronous exception is not caught, and becomes the final value of the foo thunk. Hence, when the second thread takes over the evaluation of this thunk, it finds the exception and gets killed.
So what if we want to catch IO exceptions, and deal with them, but still be able to rethrow asynchronous exceptions? Is there a way? Well, yes, we can rethrow the asynchronous exception again as an asynchronous exception. From the documentation ofthrowTo again:
If the target ofthrowTo
is the calling thread, then the behaviour is the same asthrowIO
, except that the exception is thrown as an asynchronous exception. This means that if there is an enclosing pure computation, which would be the case if the current IO operation is insideunsafePerformIO
orunsafeInterleaveIO
, that computation is not permanently replaced by the exception, but is suspended as if it had received an asynchronous exception.
But then we have to specify precisely how to continue after the rethrow:
fooIO_3 :: IO Int
= fooIO `Ex.catch` \e ->
fooIO_3 case Ex.fromException (e :: Ex.SomeException) of
Just (ioerror :: IOError) -> return 0
Nothing -> do tid <- myThreadId
throwTo tid e fooIO_3
However, this code is not equivalent to the version without any catches at all, because we now explicitly restart the computation after the asynchronous exception:
# ./ExTest
37
36
Killing a1
37
36
35
34
33
32
31
98809577
(Note that the computation starts back at 37 after “Killing a1”.) I don’t think that there is a way around it (but if there is, please let me know :) ) The other problem with fooIO_3 is that it is rethrowing all exceptions asynchronously; unfortunately, there is no way to reliable detect if an an exception was thrown synchronously or asynchronously, sincethrowTo can be used to throw any kind of exception (see ghc bug 5092). At the moment, probably your best bet is to rethrow AsyncExceptions asynchronously and the rest synchronously, but that’s a “best effort” only and may break. If you are using HEAD, you can do slightly better, because there is an ‘asynchronous exception hierarchy’, based on aSomeAsyncException data type analogous toSomeException ; however, that still does not give you the ability to check if an arbitrary check was thrown synchronously or asynchronously.
Note that precisely the same problems arise with unsafeInterleaveIO . Conclusion: don’t use System.IO.Unsafe – but you already knew that.
Further reading: a number of ghc bugs are related to this problem; see the discussion at 2558, 3997 and 5092 ; an old email thread on haskell-cafe, and the documentation of Control.Exception.