Skip to content

Commit eb09492

Browse files
csm10495pre-commit-ci[bot]gaborbernat
authored
Make the lock a thread local variable (#219)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bernát Gábor <[email protected]>
1 parent fc38859 commit eb09492

File tree

2 files changed

+82
-22
lines changed

2 files changed

+82
-22
lines changed

src/filelock/_api.py

+15-22
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import time
77
import warnings
88
from abc import ABC, abstractmethod
9-
from threading import Lock
9+
from threading import local
1010
from types import TracebackType
1111
from typing import Any
1212

@@ -36,7 +36,7 @@ def __exit__(
3636
self.lock.release()
3737

3838

39-
class BaseFileLock(ABC, contextlib.ContextDecorator):
39+
class BaseFileLock(ABC, contextlib.ContextDecorator, local):
4040
"""Abstract base class for a file lock object."""
4141

4242
def __init__(
@@ -67,9 +67,6 @@ def __init__(
6767
# The mode for the lock files
6868
self._mode: int = mode
6969

70-
# We use this lock primarily for the lock counter.
71-
self._thread_lock: Lock = Lock()
72-
7370
# The lock counter is used for implementing the nested locking mechanism. Whenever the lock is acquired, the
7471
# counter is increased and the lock is only released, when this value is 0 again.
7572
self._lock_counter: int = 0
@@ -168,18 +165,16 @@ def acquire(
168165
poll_interval = poll_intervall
169166

170167
# Increment the number right at the beginning. We can still undo it, if something fails.
171-
with self._thread_lock:
172-
self._lock_counter += 1
168+
self._lock_counter += 1
173169

174170
lock_id = id(self)
175171
lock_filename = self._lock_file
176172
start_time = time.perf_counter()
177173
try:
178174
while True:
179-
with self._thread_lock:
180-
if not self.is_locked:
181-
_LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename)
182-
self._acquire()
175+
if not self.is_locked:
176+
_LOGGER.debug("Attempting to acquire lock %s on %s", lock_id, lock_filename)
177+
self._acquire()
183178
if self.is_locked:
184179
_LOGGER.debug("Lock %s acquired on %s", lock_id, lock_filename)
185180
break
@@ -194,8 +189,7 @@ def acquire(
194189
_LOGGER.debug(msg, lock_id, lock_filename, poll_interval)
195190
time.sleep(poll_interval)
196191
except BaseException: # Something did go wrong, so decrement the counter.
197-
with self._thread_lock:
198-
self._lock_counter = max(0, self._lock_counter - 1)
192+
self._lock_counter = max(0, self._lock_counter - 1)
199193
raise
200194
return AcquireReturnProxy(lock=self)
201195

@@ -206,17 +200,16 @@ def release(self, force: bool = False) -> None:
206200
207201
:param force: If true, the lock counter is ignored and the lock is released in every case/
208202
"""
209-
with self._thread_lock:
210-
if self.is_locked:
211-
self._lock_counter -= 1
203+
if self.is_locked:
204+
self._lock_counter -= 1
212205

213-
if self._lock_counter == 0 or force:
214-
lock_id, lock_filename = id(self), self._lock_file
206+
if self._lock_counter == 0 or force:
207+
lock_id, lock_filename = id(self), self._lock_file
215208

216-
_LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename)
217-
self._release()
218-
self._lock_counter = 0
219-
_LOGGER.debug("Lock %s released on %s", lock_id, lock_filename)
209+
_LOGGER.debug("Attempting to release lock %s on %s", lock_id, lock_filename)
210+
self._release()
211+
self._lock_counter = 0
212+
_LOGGER.debug("Lock %s released on %s", lock_id, lock_filename)
220213

221214
def __enter__(self) -> BaseFileLock:
222215
"""

tests/test_filelock.py

+67
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
import os
66
import sys
77
import threading
8+
from concurrent.futures import ThreadPoolExecutor
89
from contextlib import contextmanager
910
from errno import ENOSYS
1011
from inspect import getframeinfo, stack
1112
from pathlib import Path, PurePath
1213
from stat import S_IWGRP, S_IWOTH, S_IWUSR, filemode
1314
from types import TracebackType
1415
from typing import Callable, Iterator, Tuple, Type, Union
16+
from uuid import uuid4
1517

1618
import pytest
1719
from _pytest.logging import LogCaptureFixture
@@ -81,6 +83,10 @@ def tmp_path_ro(tmp_path: Path) -> Iterator[Path]:
8183

8284
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
8385
@pytest.mark.skipif(sys.platform == "win32", reason="Windows does not have read only folders")
86+
@pytest.mark.skipif(
87+
sys.platform != "win32" and os.geteuid() == 0, # noqa: SC200
88+
reason="Cannot make a read only file (that the current user: root can't read)",
89+
)
8490
def test_ro_folder(lock_type: type[BaseFileLock], tmp_path_ro: Path) -> None:
8591
lock = lock_type(str(tmp_path_ro / "a"))
8692
with pytest.raises(PermissionError, match="Permission denied"):
@@ -96,6 +102,10 @@ def tmp_file_ro(tmp_path: Path) -> Iterator[Path]:
96102

97103

98104
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
105+
@pytest.mark.skipif(
106+
sys.platform != "win32" and os.geteuid() == 0, # noqa: SC200
107+
reason="Cannot make a read only file (that the current user: root can't read)",
108+
)
99109
def test_ro_file(lock_type: type[BaseFileLock], tmp_file_ro: Path) -> None:
100110
lock = lock_type(str(tmp_file_ro))
101111
with pytest.raises(PermissionError, match="Permission denied"):
@@ -509,3 +519,60 @@ def test_soft_errors(tmp_path: Path, mocker: MockerFixture) -> None:
509519
mocker.patch("os.open", side_effect=OSError(ENOSYS, "mock error"))
510520
with pytest.raises(OSError, match="mock error"):
511521
SoftFileLock(tmp_path / "a.lock").acquire()
522+
523+
524+
def _check_file_read_write(txt_file: Path) -> None:
525+
for _ in range(3):
526+
uuid = str(uuid4())
527+
txt_file.write_text(uuid)
528+
assert txt_file.read_text() == uuid
529+
530+
531+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
532+
def test_thrashing_with_thread_pool_passing_lock_to_threads(tmp_path: Path, lock_type: type[BaseFileLock]) -> None:
533+
def mess_with_file(lock_: BaseFileLock) -> None:
534+
with lock_:
535+
_check_file_read_write(txt_file)
536+
537+
lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt"
538+
lock = lock_type(lock_file)
539+
results = []
540+
with ThreadPoolExecutor() as executor:
541+
for _ in range(100):
542+
results.append(executor.submit(mess_with_file, lock))
543+
544+
assert all(r.result() is None for r in results)
545+
546+
547+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
548+
def test_thrashing_with_thread_pool_global_lock(tmp_path: Path, lock_type: type[BaseFileLock]) -> None:
549+
def mess_with_file() -> None:
550+
with lock:
551+
_check_file_read_write(txt_file)
552+
553+
lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt"
554+
lock = lock_type(lock_file)
555+
results = []
556+
with ThreadPoolExecutor() as executor:
557+
for _ in range(100):
558+
results.append(executor.submit(mess_with_file))
559+
560+
assert all(r.result() is None for r in results)
561+
562+
563+
@pytest.mark.parametrize("lock_type", [FileLock, SoftFileLock])
564+
def test_thrashing_with_thread_pool_lock_recreated_in_each_thread(
565+
tmp_path: Path,
566+
lock_type: type[BaseFileLock],
567+
) -> None:
568+
def mess_with_file() -> None:
569+
with lock_type(lock_file):
570+
_check_file_read_write(txt_file)
571+
572+
lock_file, txt_file = tmp_path / "test.txt.lock", tmp_path / "test.txt"
573+
results = []
574+
with ThreadPoolExecutor() as executor:
575+
for _ in range(100):
576+
results.append(executor.submit(mess_with_file))
577+
578+
assert all(r.result() is None for r in results)

0 commit comments

Comments
 (0)