Repository: mlco2/codecarbon

Issue:
Exceptions when running codecarbon from a thread
* CodeCarbon version: 2.8.0
* Python version: 3.12
* Operating System: Ubuntu

### Description

When codecarbon OfflineEmissionsTracker is running from a thread there are some exceptions : 

```python
codecarbon_tracker = OfflineEmissionsTracker(country_iso_code="FRA", save_to_file=False)
```

```
== APP == [codecarbon INFO @ 20:02:49] offline tracker init
== APP == [codecarbon WARNING @ 20:02:49] graceful shutdown. Exceptions:
== APP == [codecarbon WARNING @ 20:02:49] <class 'Exception'>
== APP == Traceback (most recent call last):
== APP ==   File "/home/ignatiev/projects/****/.venv/lib/python3.12/site-packages/codecarbon/core/util.py", line 24, in suppress
== APP ==     yield
== APP ==   File "/usr/lib/python3.12/contextlib.py", line 81, in inner
== APP ==     return func(*args, **kwds)
== APP ==            ^^^^^^^^^^^^^^^^^^^
== APP ==   File "/home/ignatiev/projects/****/.venv/lib/python3.12/site-packages/codecarbon/emissions_tracker.py", line 976, in __init__
== APP ==     super().__init__(*args, **kwargs)
== APP ==   File "/home/ignatiev/projects/****/.venv/lib/python3.12/site-packages/codecarbon/emissions_tracker.py", line 251, in __init__
== APP ==     self._lock = Lock()
== APP ==                  ^^^^^^
== APP ==   File "/home/ignatiev/projects/****/.venv/lib/python3.12/site-packages/codecarbon/lock.py", line 29, in __init__
== APP ==     signal.signal(signal.SIGINT, self._handle_exit)  # Ctrl+C
== APP ==     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
== APP ==   File "/usr/lib/python3.12/signal.py", line 58, in signal
== APP ==     handler = _signal.signal(_enum_to_int(signalnum), _enum_to_int(handler))
== APP ==               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
== APP == ValueError: signal only works in main thread of the main interpreter
== APP == [codecarbon WARNING @ 20:02:49] stopping.
== APP == [codecarbon WARNING @ 20:02:49] graceful shutdown. Exceptions:
== APP == [codecarbon WARNING @ 20:02:49] <class 'Exception'>
== APP == Traceback (most recent call last):
== APP ==   File "/home/ignatiev/projects/****/.venv/lib/python3.12/site-packages/codecarbon/core/util.py", line 24, in suppress
== APP ==     yield
== APP ==   File "/usr/lib/python3.12/contextlib.py", line 81, in inner
== APP ==     return func(*args, **kwds)
== APP ==            ^^^^^^^^^^^^^^^^^^^
== APP ==   File "/home/ignatiev/projects/***/.venv/lib/python3.12/site-packages/codecarbon/emissions_tracker.py", line 541, in start
== APP ==     if self._start_time is not None:
== APP ==        ^^^^^^^^^^^^^^^^
== APP == AttributeError: 'OfflineEmissionsTracker' object has no attribute '_start_time'. Did you mean: 'start_task'?
== APP == [codecarbon WARNING @ 20:02:49] stopping.

```

### What I Did

Adding `allow_multiple_runs=True` resolved a problem but I suppose allow_multiple_runs=True is more a workaround than a good practice to handle this issue.

Should be other lock types used?

Discussion:
@benoit-cty: Thanks, `signal` is used to catch the Ctrl-C for deleting the lock file we use to prevent multiple run.

I think we could just catch and ignore this exception. And just log a warning. @inimaz have you a better idea ?@inimaz: Thanks! Sadly apparently `signal`  works only in single-thread. We can just warn as you suggest @benoit-cty ! On the long-term, we should as well do a version of this lock multi-thread safe. Otherwise multithread might not remove the lock when they do Ctrl+C.@ivanignatiev: Regarding to [Stackoverflow](https://stackoverflow.com/a/47148962/1768393), it looks like we can verify if Lock is initializing from main thread with:

```
if threading.current_thread() is threading.main_thread():
    ...
```

@inimaz: @ivanignatiev I have added a fix  here --> #737. Note that still If you run codecarbon on 10 threads at the same time (10 instances of codecarbon), only one instance of codecarbon will be really monitoring (since they are sharing CPU, RAM, GPU...) and the other 9 will say 
`Error: Another instance of codecarbon is probably running as we find /tmp/.codecarbon.lock. Turn off the other instance to be able to run this one or use allow_multiple_runs or delete the file. Exiting.`
Is this what you expect? @benoit-cty: Hello, it has been published in the v2.8.2 of CodeCarbon.

Solution patch:
diff --git a/codecarbon/lock.py b/codecarbon/lock.py
index be075f62..b7a0215d 100644
--- a/codecarbon/lock.py
+++ b/codecarbon/lock.py
@@ -9,6 +9,7 @@ import errno
 import os
 import signal
 import tempfile
+import threading
 
 from codecarbon.external.logger import logger
 
@@ -25,9 +26,13 @@ class Lock:
         atexit.register(
             self.release
         )  # Ensure release() is called on unexpected exit of the user's python code
-        # Register signal handlers to ensure lock release on interruption
-        signal.signal(signal.SIGINT, self._handle_exit)  # Ctrl+C
-        signal.signal(signal.SIGTERM, self._handle_exit)  # Termination signal
+        # If there is more than one thread add a lock
+        self._thread_lock = threading.Lock()
+        # If the current thread is the main thread, register signal handlers
+        if threading.current_thread() is threading.main_thread():
+            # Register signal handlers to ensure lock release on interruption
+            signal.signal(signal.SIGINT, self._handle_exit)  # Ctrl+C
+            signal.signal(signal.SIGTERM, self._handle_exit)  # Termination signal
 
     def _handle_exit(self, signum, frame):
         """Ensures the lock file is removed when the script is interrupted."""
@@ -37,25 +42,27 @@ class Lock:
 
     def acquire(self):
         """Creates a lock file and ensures it's the only instance running."""
-        # Attempt to create the lock file
-        try:
-            with open(LOCKFILE, "x") as _:
-                logger.debug(f"Lock file created. Path: {LOCKFILE}")
-                self._has_created_lock = True
-        except FileExistsError:
-            logger.debug(
-                f"Lock file {LOCKFILE} already exists. This usually means another instance of codecarbon is running. You can safely delete it if you want or use allow_multiple_runs parameter to always bypass it."
-            )
-            raise
+        with self._thread_lock:
+            # Attempt to create the lock file
+            try:
+                with open(LOCKFILE, "x") as _:
+                    logger.debug(f"Lock file created. Path: {LOCKFILE}")
+                    self._has_created_lock = True
+            except FileExistsError:
+                logger.debug(
+                    f"Lock file {LOCKFILE} already exists. This usually means another instance of codecarbon is running. You can safely delete it if you want or use allow_multiple_runs parameter to always bypass it."
+                )
+                raise
 
     def release(self):
         """Removes the lock file on exit."""
-        logger.debug("Removing the lock")
-        try:
-            # Remove the lock file only if it was created by this instance
-            if self._has_created_lock:
-                os.remove(LOCKFILE)
-        except OSError as e:
-            logger.debug(f"Error: {e}")
-            if e.errno != errno.ENOENT:
-                raise
+        with self._thread_lock:
+            logger.debug("Removing the lock")
+            try:
+                # Remove the lock file only if it was created by this instance
+                if self._has_created_lock:
+                    os.remove(LOCKFILE)
+            except OSError as e:
+                logger.debug(f"Error: {e}")
+                if e.errno != errno.ENOENT:
+                    raise
diff --git a/examples/multithread.py b/examples/multithread.py
new file mode 100644
index 00000000..9c142d54
--- /dev/null
+++ b/examples/multithread.py
@@ -0,0 +1,37 @@
+import random
+import threading
+import time
+
+from codecarbon import OfflineEmissionsTracker
+
+
+def tracker_example():
+    """
+    This function will do nothing during (occurrence * delay) seconds.
+    """
+    occurrence = random.randint(1, 3)  # Run for 1 to 3 seconds
+    tracker = OfflineEmissionsTracker(
+        country_iso_code="FRA",
+        measure_power_secs=30,
+        project_name="ultra_secret",
+    )
+
+    tracker.start()
+    try:
+        delay = 1  # Seconds
+        for _ in range(occurrence):
+            time.sleep(delay)
+    finally:
+        tracker.stop()
+
+
+if __name__ == "__main__":
+
+    threads = []
+    for _ in range(3):
+        t = threading.Thread(target=tracker_example)
+        threads.append(t)
+        t.start()
+
+    for t in threads:
+        t.join()


Test patch:
diff --git a/tests/test_lock.py b/tests/test_lock.py
index de298d20..aafb46a1 100644
--- a/tests/test_lock.py
+++ b/tests/test_lock.py
@@ -1,3 +1,4 @@
+import threading
 import unittest
 from unittest.mock import mock_open, patch
 
@@ -38,6 +39,43 @@ class TestLock(unittest.TestCase):
         mock_remove.assert_not_called()
         self.assertFalse(self.lock._has_created_lock)
 
+    @patch("codecarbon.lock.os.remove")
+    @patch("codecarbon.lock.open", new_callable=mock_open)
+    def test_acquire_release_with_multiple_threads(self, mock_file, mock_remove):
+        """
+        Test that the lock file is created and removed correctly when multiple threads are used.
+        """
+        # Simulate a complex threading scenario where the lock file is created and removed multiple times
+        # First call succeeds, second fails, third succeeds, subsequent calls raise FileExistsError
+        mock_file.side_effect = (
+            [mock_open().return_value]
+            + [FileExistsError]
+            + [mock_open().return_value]
+            + [FileExistsError] * 7
+        )
+
+        def thread_target():
+            # Create a lock instance in each thread
+            lock = Lock()
+            try:
+                lock.acquire()
+            except Exception:
+                pass
+            finally:
+                lock.release()
+
+        threads = [threading.Thread(target=thread_target, args=()) for _ in range(10)]
+
+        for thread in threads:
+            thread.start()
+
+        for thread in threads:
+            thread.join()
+
+        # Ensure that the lock file was created and removed correctly
+        self.assertTrue(mock_file.called)
+        self.assertTrue(mock_remove.called)
+
 
 if __name__ == "__main__":
     unittest.main()