Repository: numba/numba

Issue:
Compiler lock is unhelpful
When calling an autojit function from several threads, it is possible to get the following error:

```
Exception in thread Thread-14:
Traceback (most recent call last):
  File "/home/antoine/34/lib/python3.4/threading.py", line 921, in _bootstrap_inner
    self.run()
  File "/home/antoine/34/lib/python3.4/threading.py", line 869, in run
    self._target(*self._args, **self._kwargs)
  File "/home/antoine/numba/numba/dispatcher.py", line 151, in _compile_for_args
    return self.jit(sig)
  File "/home/antoine/numba/numba/dispatcher.py", line 141, in jit
    return self.compile(sig, **kws)
  File "/home/antoine/numba/numba/dispatcher.py", line 312, in compile
    with self._compile_lock():
  File "/home/antoine/34/lib/python3.4/contextlib.py", line 59, in __enter__
    return next(self.gen)
  File "/home/antoine/numba/numba/dispatcher.py", line 127, in _compile_lock
    raise RuntimeError("Compiler re-entrant")
RuntimeError: Compiler re-entrant
```

I wonder if using an actual lock would pose any problems.



Discussion:
The original check did not consider multithreading.  It is to guard against mistake like infinite self compiling loops.  A no-reentrant lock will work.  Better if there is a check to guard against a thread deadlocking itself due to reentrant.  (The guard is to help debugging.)


Resolving patch:
diff --git a/numba/dispatcher.py b/numba/dispatcher.py
--- a/numba/dispatcher.py
+++ b/numba/dispatcher.py
@@ -37,7 +37,7 @@ def __init__(self, arg_count, py_func):
         self.__code__ = self.func_code
 
         self.doc = py_func.__doc__
-        self._compiling = False
+        self._compile_lock = utils.NonReentrantLock()
 
         utils.finalize(self, self._make_finalizer())
 
@@ -121,24 +121,12 @@ def get_overload(self, sig):
         args, return_type = sigutils.normalize_signature(sig)
         return self.overloads[tuple(args)]
 
-    @contextlib.contextmanager
-    def _compile_lock(self):
-        if self._compiling:
-            raise RuntimeError("Compiler re-entrant")
-        self._compiling = True
-        try:
-            yield
-        finally:
-            self._compiling = False
-
     @property
     def is_compiling(self):
-        return self._compiling
-
-    def jit(self, sig, **kws):
-        """Alias of compile(sig, **kws)
         """
-        return self.compile(sig, **kws)
+        Whether a specialization is currently being compiled.
+        """
+        return self._compile_lock.is_owned()
 
     def _compile_for_args(self, *args, **kws):
         """
@@ -147,7 +135,7 @@ def _compile_for_args(self, *args, **kws):
         """
         assert not kws
         sig = tuple([self.typeof_pyval(a) for a in args])
-        return self.jit(sig)
+        return self.compile(sig)
 
     def inspect_types(self, file=None):
         if file is None:
@@ -253,7 +241,14 @@ def _rebuild(cls, func_reduced, locals, targetoptions, can_compile, sigs):
         return self
 
     def compile(self, sig, locals={}, **targetoptions):
-        with self._compile_lock():
+        with self._compile_lock:
+            args, return_type = sigutils.normalize_signature(sig)
+            # Don't recompile if signature already exists
+            # (e.g. if another thread compiled it before we got the lock)
+            existing = self.overloads.get(tuple(args))
+            if existing is not None:
+                return existing
+
             locs = self.locals.copy()
             locs.update(locals)
 
@@ -263,13 +258,6 @@ def compile(self, sig, locals={}, **targetoptions):
             flags = compiler.Flags()
             self.targetdescr.options.parse_as_flags(flags, topt)
 
-            args, return_type = sigutils.normalize_signature(sig)
-
-            # Don't recompile if signature already exist.
-            existing = self.overloads.get(tuple(args))
-            if existing is not None:
-                return existing
-
             cres = compiler.compile_extra(self.typingctx, self.targetctx,
                                           self.py_func,
                                           args=args, return_type=return_type,
@@ -308,12 +296,13 @@ def get_source_location(self):
         return next(iter(self.bytecode)).lineno
 
     def compile(self, sig):
-        with self._compile_lock():
+        with self._compile_lock:
             # FIXME this is mostly duplicated from Overloaded
             flags = self.flags
             args, return_type = sigutils.normalize_signature(sig)
 
-            # Don't recompile if signature already exist.
+            # Don't recompile if signature already exists
+            # (e.g. if another thread compiled it before we got the lock)
             existing = self.overloads.get(tuple(args))
             if existing is not None:
                 return existing.entry_point
diff --git a/numba/utils.py b/numba/utils.py
--- a/numba/utils.py
+++ b/numba/utils.py
@@ -5,6 +5,7 @@
 import functools
 import io
 import itertools
+import threading
 import timeit
 import math
 import sys
@@ -24,9 +25,12 @@
 if IS_PY3:
     INT_TYPES = (int,)
     longint = int
+    get_ident = threading.get_ident
 else:
+    import thread
     INT_TYPES = (int, long)
     longint = long
+    get_ident = thread.get_ident
 
 
 _shutting_down = False
@@ -131,6 +135,40 @@ def __setitem__(self, key, value):
         super(UniqueDict, self).__setitem__(key, value)
 
 
+class NonReentrantLock(object):
+    """
+    A lock class which explicitly forbids reentrancy.
+    """
+
+    def __init__(self):
+        self._lock = threading.Lock()
+        self._owner = None
+
+    def acquire(self):
+        me = get_ident()
+        if me == self._owner:
+            raise RuntimeError("cannot re-acquire lock from same thread")
+        self._lock.acquire()
+        self._owner = me
+
+    def release(self):
+        if self._owner != get_ident():
+            raise RuntimeError("cannot release un-acquired lock")
+        self._owner = None
+        self._lock.release()
+
+    def is_owned(self):
+        """
+        Whether the lock is owned by the current thread.
+        """
+        return self._owner == get_ident()
+
+    __enter__ = acquire
+
+    def __exit__(self, t, v, tb):
+        self.release()
+
+
 # Django's cached_property
 # see https://docs.djangoproject.com/en/dev/ref/utils/#django.utils.functional.cached_property
 


Test patch:
diff --git a/numba/tests/test_dispatcher.py b/numba/tests/test_dispatcher.py
--- a/numba/tests/test_dispatcher.py
+++ b/numba/tests/test_dispatcher.py
@@ -1,6 +1,7 @@
 from __future__ import print_function, division, absolute_import
 
 import numpy
+import threading
 
 from numba import unittest_support as unittest
 from numba.special import typeof
@@ -81,6 +82,28 @@ def foo(a, b):
         self.assertEqual(len(foo.overloads), 4, "didn't compile a new "
                                                 "version")
 
+    def test_lock(self):
+        """
+        Test that (lazy) compiling from several threads at once doesn't
+        produce errors (see issue #908).
+        """
+        errors = []
+        @jit
+        def foo(x):
+            return x + 1
+        def wrapper():
+            try:
+                self.assertEqual(foo(1), 2)
+            except BaseException as e:
+                errors.append(e)
+
+        threads = [threading.Thread(target=wrapper) for i in range(16)]
+        for t in threads:
+            t.start()
+        for t in threads:
+            t.join()
+        self.assertFalse(errors)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/numba/tests/test_utils.py b/numba/tests/test_utils.py
--- a/numba/tests/test_utils.py
+++ b/numba/tests/test_utils.py
@@ -4,6 +4,9 @@
 
 from __future__ import print_function, absolute_import
 
+import threading
+import time
+
 from numba import utils
 from numba import unittest_support as unittest
 
@@ -78,5 +81,63 @@ def test_total_ordering_derived(self):
         self.check_total_ordering(D)
 
 
+class TestNonReentrantLock(unittest.TestCase):
+
+    def _lock(self):
+        return utils.NonReentrantLock()
+
+    def test_acquire_release(self):
+        lock = self._lock()
+        self.assertFalse(lock.is_owned())
+        lock.acquire()
+        self.assertTrue(lock.is_owned())
+        lock.release()
+        self.assertFalse(lock.is_owned())
+        with self.assertRaises(RuntimeError):
+            lock.release()
+        lock.acquire()
+        self.assertTrue(lock.is_owned())
+        with self.assertRaises(RuntimeError):
+            lock.acquire()
+        self.assertTrue(lock.is_owned())
+        lock.release()
+        lock.acquire()
+        lock.release()
+        self.assertFalse(lock.is_owned())
+
+    def test_multithreaded(self):
+        lock = self._lock()
+        errors = []
+
+        def do_things():
+            for i in range(5):
+                self.assertFalse(lock.is_owned())
+                lock.acquire()
+                time.sleep(1e-4)
+                self.assertTrue(lock.is_owned())
+                lock.release()
+        def wrapper():
+            try:
+                do_things()
+            except BaseException as e:
+                errors.append(e)
+
+        threads = [threading.Thread(target=wrapper) for i in range(40)]
+        for t in threads:
+            t.start()
+        for t in threads:
+            t.join()
+        self.assertFalse(errors)
+        self.assertFalse(lock.is_owned())
+
+    def test_with(self):
+        lock = self._lock()
+        with lock:
+            self.assertTrue(lock.is_owned())
+            self.assertRaises(RuntimeError, lock.acquire)
+        self.assertFalse(lock.is_owned())
+        self.assertRaises(RuntimeError, lock.release)
+
+
 if __name__ == '__main__':
     unittest.main()