Repository: glotzerlab/signac

Issue:
Use custom context managers rather than contextlib
Noted by @bdice in [this comment](https://github.com/glotzerlab/signac/pull/463/files#r552232801) #463, contextlib's contextmanagers have a lot of overhead. We should benchmark these in SyncedCollections since we use many of them and if appropriate, write our own simpler context managers to improve performance.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=h1) Report
> Merging [#470](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=desc) (31c92a7) into [feature/synced_collections](https://codecov.io/gh/glotzerlab/signac/commit/8c72b08552ca3839f5ea19fdc4bca55379baa4db?el=desc) (8c72b08) will **increase** coverage by `0.09%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/glotzerlab/signac/pull/470/graphs/tree.svg?width=650&height=150&src=pr&token=FghbjTyzgM)](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=tree)

```diff
@@                      Coverage Diff                       @@
##           feature/synced_collections     #470      +/-   ##
==============================================================
+ Coverage                       79.00%   79.09%   +0.09%     
==============================================================
  Files                              58       58              
  Lines                            6731     6741      +10     
  Branches                         1246     1248       +2     
==============================================================
+ Hits                             5318     5332      +14     
+ Misses                           1117     1115       -2     
+ Partials                          296      294       -2     
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [...nac/core/synced\_collections/buffered\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL2J1ZmZlcmVkX2NvbGxlY3Rpb24ucHk=) | `88.70% <100.00%> (-2.43%)` | :arrow_down: |
| [signac/core/synced\_collections/collection\_json.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL2NvbGxlY3Rpb25fanNvbi5weQ==) | `100.00% <100.00%> (ø)` | |
| [...ore/synced\_collections/file\_buffered\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL2ZpbGVfYnVmZmVyZWRfY29sbGVjdGlvbi5weQ==) | `96.77% <100.00%> (-0.04%)` | :arrow_down: |
| [...e/synced\_collections/memory\_buffered\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL21lbW9yeV9idWZmZXJlZF9jb2xsZWN0aW9uLnB5) | `100.00% <100.00%> (+5.55%)` | :arrow_up: |
| [...collections/serialized\_file\_buffered\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL3NlcmlhbGl6ZWRfZmlsZV9idWZmZXJlZF9jb2xsZWN0aW9uLnB5) | `98.48% <100.00%> (+1.51%)` | :arrow_up: |
| [signac/core/synced\_collections/synced\_attr\_dict.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL3N5bmNlZF9hdHRyX2RpY3QucHk=) | `100.00% <100.00%> (ø)` | |
| [...ignac/core/synced\_collections/synced\_collection.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL3N5bmNlZF9jb2xsZWN0aW9uLnB5) | `91.89% <100.00%> (-0.27%)` | :arrow_down: |
| [signac/core/synced\_collections/synced\_list.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL3N5bmNlZF9saXN0LnB5) | `99.02% <100.00%> (ø)` | |
| [signac/core/synced\_collections/utils.py](https://codecov.io/gh/glotzerlab/signac/pull/470/diff?src=pr&el=tree#diff-c2lnbmFjL2NvcmUvc3luY2VkX2NvbGxlY3Rpb25zL3V0aWxzLnB5) | `82.25% <100.00%> (+9.75%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=footer). Last update [8c72b08...31c92a7](https://codecov.io/gh/glotzerlab/signac/pull/470?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

bdice: @vyasr Also can you profile / benchmark this PR compared to `contextmanager`?
vyasr: @bdice I ran some longer benchmarks than my originals to try to capture the difference. It works out to a 2-6% performance increase for common operations (setitem, keys, getitem, values, call).

Patch:
diff --git a/signac/core/synced_collections/buffered_collection.py b/signac/core/synced_collections/buffered_collection.py
index 528c7802..1ea446ad 100644
--- a/signac/core/synced_collections/buffered_collection.py
+++ b/signac/core/synced_collections/buffered_collection.py
@@ -36,12 +36,12 @@ buffer flushes will occur when all such managers have been exited.
 """
 
 import logging
-from contextlib import contextmanager
 from inspect import isabstract
 from typing import Any, List
 
 from .errors import BufferedError
 from .synced_collection import SyncedCollection
+from .utils import _CounterFuncContext
 
 logger = logging.getLogger(__name__)
 
@@ -81,7 +81,6 @@ class BufferedCollection(SyncedCollection):
 
     """
 
-    _BUFFERED_MODE = 0
     _BUFFERED_BACKENDS: List[Any] = []
 
     def __init__(self, *args, **kwargs):
@@ -91,7 +90,7 @@ class BufferedCollection(SyncedCollection):
         # getattr need to access the synced data, they may call sync and load,
         # which depend on this parameter existing and could otherwise end up in
         # an infinite recursion.
-        self._buffered = 0
+        self.buffered = _CounterFuncContext(self._flush)
         super().__init__(*args, **kwargs)
 
     @classmethod
@@ -104,28 +103,6 @@ class BufferedCollection(SyncedCollection):
         if not isabstract(cls):
             BufferedCollection._BUFFERED_BACKENDS.append(cls)
 
-    @staticmethod
-    @contextmanager
-    def buffer_all():
-        """Enter a globally buffer context for all BufferedCollection instances.
-
-        All future operations use the buffer whenever possible. Write operations
-        are deferred until the context is exited, at which point all buffered
-        backends will flush their buffers. Individual backends may flush their
-        buffers within this context if the implementation requires it; this context
-        manager represents a promise to buffer whenever possible, but does not
-        guarantee that no writes will occur under all circumstances.
-        """
-        assert BufferedCollection._BUFFERED_MODE >= 0
-
-        BufferedCollection._BUFFERED_MODE += 1
-        try:
-            yield
-        finally:
-            BufferedCollection._BUFFERED_MODE -= 1
-            if BufferedCollection._BUFFERED_MODE == 0:
-                BufferedCollection._flush_all_backends()
-
     @staticmethod
     def _flush_all_backends():
         """Execute all deferred write operations.
@@ -156,14 +133,14 @@ class BufferedCollection(SyncedCollection):
         `sync` except that it determines whether data is actually synchronized
         or instead written to a temporary buffer based on the buffering mode.
         """
-        if self._suspend_sync_ <= 0:
-            if self._parent is None:
+        if not self._suspend_sync:
+            if self._root is None:
                 if self._is_buffered:
                     self._save_to_buffer()
                 else:
                     self._save_to_resource()
             else:
-                self._parent._save()
+                self._root._save()
 
     def _load(self):
         """Load data from the backend but buffer if needed.
@@ -172,16 +149,16 @@ class BufferedCollection(SyncedCollection):
         `load` except that it determines whether data is actually synchronized
         or instead read from a temporary buffer based on the buffering mode.
         """
-        if self._suspend_sync_ <= 0:
-            if self._parent is None:
+        if not self._suspend_sync:
+            if self._root is None:
                 if self._is_buffered:
                     data = self._load_from_buffer()
                 else:
                     data = self._load_from_resource()
-                with self._suspend_sync():
+                with self._suspend_sync:
                     self._update(data)
             else:
-                self._parent._load()
+                self._root._load()
 
     def _save_to_buffer(self):
         """Store data in buffer.
@@ -207,21 +184,10 @@ class BufferedCollection(SyncedCollection):
         """
         self._load_from_resource()
 
-    @contextmanager
-    def buffered(self):
-        """Enter buffered mode."""
-        self._buffered += 1
-        try:
-            yield
-        finally:
-            self._buffered -= 1
-            if not self._is_buffered:
-                self._flush()
-
     @property
     def _is_buffered(self):
         """Check if we should write to the buffer or not."""
-        return self._buffered + BufferedCollection._BUFFERED_MODE > 0
+        return self.buffered or _BUFFER_ALL_CONTEXT
 
     def _flush(self):
         """Flush data associated with this instance from the buffer."""
@@ -233,4 +199,22 @@ class BufferedCollection(SyncedCollection):
         pass
 
 
-buffer_all = BufferedCollection.buffer_all
+# This module-scope variable is a context that can be accessed via the
+# buffer_all method for the purpose of buffering all subsequence read and write
+# operations.
+_BUFFER_ALL_CONTEXT = _CounterFuncContext(BufferedCollection._flush_all_backends)
+
+
+# This function provides a more familiar module-scope, function-based interface
+# for enabling buffering rather than calling the class's static method.
+def buffer_all():
+    """Return a global buffer context for all BufferedCollection instances.
+
+    All future operations use the buffer whenever possible. Write operations
+    are deferred until the context is exited, at which point all buffered
+    backends will flush their buffers. Individual backends may flush their
+    buffers within this context if the implementation requires it; this context
+    manager represents a promise to buffer whenever possible, but does not
+    guarantee that no writes will occur under all circumstances.
+    """
+    return _BUFFER_ALL_CONTEXT
diff --git a/signac/core/synced_collections/collection_json.py b/signac/core/synced_collections/collection_json.py
index 346ad81a..658a533e 100644
--- a/signac/core/synced_collections/collection_json.py
+++ b/signac/core/synced_collections/collection_json.py
@@ -294,7 +294,7 @@ class JSONList(JSONCollection, SyncedList):
 class BufferedJSONDict(BufferedJSONCollection, SyncedAttrDict):
     """A buffered :class:`JSONDict`."""
 
-    _PROTECTED_KEYS = SyncedAttrDict._PROTECTED_KEYS + (
+    _PROTECTED_KEYS = (
         "_filename",
         "_buffered",
         "_is_buffered",
diff --git a/signac/core/synced_collections/collection_zarr.py b/signac/core/synced_collections/collection_zarr.py
index 89612d35..7a876185 100644
--- a/signac/core/synced_collections/collection_zarr.py
+++ b/signac/core/synced_collections/collection_zarr.py
@@ -38,7 +38,7 @@ class ZarrCollection(SyncedCollection):
     def __init__(self, group=None, name=None, codec=None, **kwargs):
         import numcodecs
 
-        self._root = group
+        self._group = group
         self._name = name
         self._object_codec = numcodecs.JSON() if codec is None else codec
         super().__init__(**kwargs)
@@ -54,14 +54,14 @@ class ZarrCollection(SyncedCollection):
 
         """
         try:
-            return self._root[self._name][0]
+            return self._group[self._name][0]
         except KeyError:
             return None
 
     def _save_to_resource(self):
         """Write the data to Zarr group."""
         data = self._to_base()
-        dataset = self._root.require_dataset(
+        dataset = self._group.require_dataset(
             self._name,
             overwrite=True,
             shape=1,
@@ -71,16 +71,16 @@ class ZarrCollection(SyncedCollection):
         dataset[0] = data
 
     def __deepcopy__(self, memo):
-        if self._parent is not None:
+        if self._root is not None:
             return type(self)(
                 group=None,
                 name=None,
                 data=self._to_base(),
-                parent=deepcopy(self._parent, memo),
+                parent=deepcopy(self._root, memo),
             )
         else:
             return type(self)(
-                group=deepcopy(self._root, memo),
+                group=deepcopy(self._group, memo),
                 name=self._name,
                 data=None,
                 parent=None,
@@ -98,7 +98,7 @@ class ZarrCollection(SyncedCollection):
     @property
     def group(self):
         """zarr.hierarchy.Group: The Zarr group storing the data."""
-        return self._root
+        return self._group
 
     @property
     def name(self):
diff --git a/signac/core/synced_collections/file_buffered_collection.py b/signac/core/synced_collections/file_buffered_collection.py
index b3ee4263..da354130 100644
--- a/signac/core/synced_collections/file_buffered_collection.py
+++ b/signac/core/synced_collections/file_buffered_collection.py
@@ -12,24 +12,29 @@ specific components are abstract and must be implemented by child classes.
 import errno
 import os
 from abc import abstractmethod
-from contextlib import contextmanager
 from threading import RLock
 from typing import Dict, Tuple, Union
 
 from .buffered_collection import BufferedCollection
 from .errors import MetadataError
-from .synced_collection import _fake_lock
+from .synced_collection import _LoadAndSave
+from .utils import _NullContext
 
 
-@contextmanager
-def _buffer_lock(self):
-    """Prepare context for thread-safe operation.
+class _BufferedLoadAndSave(_LoadAndSave):
+    """Wrap base loading and saving with an extra thread lock.
 
-    All operations that can mutate an object should use this context
-    manager to ensure thread safety.
+    Writes to buffered collections will also modify the buffer, so they must
+    acquire the buffer lock in addition to the default behavior.
     """
-    with type(self)._BUFFER_LOCK:
-        yield
+
+    def __enter__(self):
+        self._collection._buffer_lock.__enter__()
+        super().__enter__()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        super().__exit__(exc_type, exc_val, exc_tb)
+        self._collection._buffer_lock.__exit__(exc_type, exc_val, exc_tb)
 
 
 class FileBufferedCollection(BufferedCollection):
@@ -75,8 +80,10 @@ class FileBufferedCollection(BufferedCollection):
 
     """
 
-    def __init__(self, filename=None, *args, **kwargs):
-        super().__init__(filename=filename, *args, **kwargs)
+    _LoadSaveType = _BufferedLoadAndSave
+
+    def __init__(self, parent=None, filename=None, *args, **kwargs):
+        super().__init__(parent=parent, filename=filename, *args, **kwargs)
         self._filename = filename
 
     @classmethod
@@ -84,7 +91,6 @@ class FileBufferedCollection(BufferedCollection):
         """Prepare subclasses."""
         super().__init_subclass__()
         cls._CURRENT_BUFFER_SIZE = 0
-        cls._BUFFER_LOCK = RLock()
 
         # This dict is the actual data buffer, mapping filenames to their
         # cached data and metadata.
@@ -106,7 +112,6 @@ class FileBufferedCollection(BufferedCollection):
 
         """
         super().enable_multithreading()
-        cls._buffer_lock = _buffer_lock
         cls._BUFFER_LOCK = RLock()
 
     @classmethod
@@ -118,8 +123,12 @@ class FileBufferedCollection(BufferedCollection):
 
         """
         super().disable_multithreading()
-        cls._buffer_lock = _fake_lock
-        cls._BUFFER_LOCK = _fake_lock()
+        cls._BUFFER_LOCK = _NullContext()
+
+    @property
+    def _buffer_lock(self):
+        """Acquire the buffer lock."""
+        return type(self)._BUFFER_LOCK
 
     def _get_file_metadata(self):
         """Return metadata of file.
@@ -186,17 +195,6 @@ class FileBufferedCollection(BufferedCollection):
         """
         return cls._CURRENT_BUFFER_SIZE
 
-    @contextmanager
-    def _load_and_save(self):
-        """Prepare a context manager in which mutating changes can happen.
-
-        Override the parent function's hook to support safely multithreaded
-        access to the buffer.
-        """
-        with self._buffer_lock():
-            with super()._load_and_save():
-                yield
-
     def _load_from_buffer(self):
         """Read data from buffer.
 
@@ -211,7 +209,7 @@ class FileBufferedCollection(BufferedCollection):
             underlying file.
 
         """
-        with self._buffer_lock():
+        with self._buffer_lock:
             if self._filename not in type(self)._buffer:
                 # The first time this method is called, if nothing is in the buffer
                 # for this file then we cannot guarantee that the _data attribute
@@ -220,9 +218,8 @@ class FileBufferedCollection(BufferedCollection):
                 # called. As a result, we have to load from the resource here to be
                 # safe.
                 data = self._load_from_resource()
-                with self._thread_lock():
-                    with self._suspend_sync():
-                        self._update(data)
+                with self._thread_lock, self._suspend_sync:
+                    self._update(data)
                 self._initialize_data_in_buffer()
 
         # This storage can be safely updated every time on every thread.
diff --git a/signac/core/synced_collections/memory_buffered_collection.py b/signac/core/synced_collections/memory_buffered_collection.py
index 4a8f87fc..e3de29e5 100644
--- a/signac/core/synced_collections/memory_buffered_collection.py
+++ b/signac/core/synced_collections/memory_buffered_collection.py
@@ -151,7 +151,7 @@ class SharedMemoryFileBufferedCollection(FileBufferedCollection):
             # collection pointing to the same data flushed the buffer. This
             # object's data will still be pointing to that one, though, so the
             # safest choice is to reinitialize its data from scratch.
-            with self._suspend_sync():
+            with self._suspend_sync:
                 self._data = {
                     key: self._from_base(data=value, parent=self)
                     for key, value in self._to_base().items()
@@ -163,16 +163,16 @@ class SharedMemoryFileBufferedCollection(FileBufferedCollection):
         Override the base buffered method to skip the _update and to let
         _load_from_buffer happen "in place."
         """
-        if self._suspend_sync_ <= 0:
-            if self._parent is None:
+        if not self._suspend_sync:
+            if self._root is None:
                 if self._is_buffered:
                     self._load_from_buffer()
                 else:
                     data = self._load_from_resource()
-                    with self._suspend_sync():
+                    with self._suspend_sync:
                         self._update(data)
             else:
-                self._parent._load()
+                self._root._load()
 
     def _save_to_buffer(self):
         """Store data in buffer.
@@ -191,7 +191,7 @@ class SharedMemoryFileBufferedCollection(FileBufferedCollection):
         # those the writes will be automatically serialized because Python
         # dicts are thread-safe because of the GIL. However, it's best not to
         # depend on the thread-safety of built-in containers.
-        with self._buffer_lock():
+        with self._buffer_lock:
             if self._filename in type(self)._buffer:
                 # Always track all instances pointing to the same data.
 
diff --git a/signac/core/synced_collections/serialized_file_buffered_collection.py b/signac/core/synced_collections/serialized_file_buffered_collection.py
index 55c4b071..43749870 100644
--- a/signac/core/synced_collections/serialized_file_buffered_collection.py
+++ b/signac/core/synced_collections/serialized_file_buffered_collection.py
@@ -99,7 +99,7 @@ class SerializedFileBufferedCollection(FileBufferedCollection):
         """
         # Different files in the buffer can be safely flushed simultaneously,
         # but a given file can only be flushed on one thread at once.
-        with self._buffer_lock():
+        with self._buffer_lock:
             if not self._is_buffered or force:
                 try:
                     cached_data = type(self)._buffer[self._filename]
@@ -207,7 +207,7 @@ class SerializedFileBufferedCollection(FileBufferedCollection):
         # those the writes will be automatically serialized because Python
         # dicts are thread-safe because of the GIL. However, it's best not to
         # depend on the thread-safety of built-in containers.
-        with self._buffer_lock():
+        with self._buffer_lock:
             if self._filename in type(self)._buffer:
                 # Always track all instances pointing to the same data.
                 blob = self._encode(self._data)
@@ -248,7 +248,7 @@ class SerializedFileBufferedCollection(FileBufferedCollection):
             underlying file.
 
         """
-        with self._buffer_lock():
+        with self._buffer_lock:
             super()._load_from_buffer()
 
             # Load from buffer. This has to happen inside the locked context
diff --git a/signac/core/synced_collections/synced_attr_dict.py b/signac/core/synced_collections/synced_attr_dict.py
index fdfa6e32..03d3fe09 100644
--- a/signac/core/synced_collections/synced_attr_dict.py
+++ b/signac/core/synced_collections/synced_attr_dict.py
@@ -50,8 +50,9 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         "_suspend_sync_",
         "_load",
         "_sync",
-        "_parent",
+        "_root",
         "_validators",
+        "_load_and_save",
     )
 
     def __init__(self, data=None, *args, **kwargs):
@@ -60,12 +61,12 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
             self._data = {}
         else:
             self._validate(data)
-            with self._suspend_sync():
+            with self._suspend_sync:
                 self._data = {
                     key: self._from_base(data=value, parent=self)
                     for key, value in data.items()
                 }
-            with self._thread_lock():
+            with self._thread_lock:
                 self._save()
 
     def _to_base(self):
@@ -124,7 +125,7 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         if data is None:
             self._data.clear()
         elif _mapping_resolver.get_type(data) == "MAPPING":
-            with self._suspend_sync():
+            with self._suspend_sync:
                 for key, new_value in data.items():
                     try:
                         # The most common usage of SyncedCollections is with a
@@ -175,10 +176,9 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         # directly set using those rather than looping over data.
         data = {key: value}
         self._validate(data)
-        with self._load_and_save():
-            with self._suspend_sync():
-                for key, value in data.items():
-                    self._data[key] = self._from_base(value, parent=self)
+        with self._load_and_save, self._suspend_sync:
+            for key, value in data.items():
+                self._data[key] = self._from_base(value, parent=self)
 
     def reset(self, data=None):
         """Update the instance with new data.
@@ -198,12 +198,12 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
             data = {}
         if _mapping_resolver.get_type(data) == "MAPPING":
             self._validate(data)
-            with self._suspend_sync():
+            with self._suspend_sync:
                 self._data = {
                     key: self._from_base(data=value, parent=self)
                     for key, value in data.items()
                 }
-            with self._thread_lock():
+            with self._thread_lock:
                 self._save()
         else:
             raise ValueError(
@@ -229,18 +229,18 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         return self._data.get(key, default)
 
     def pop(self, key, default=None):  # noqa: D102
-        with self._load_and_save():
+        with self._load_and_save:
             ret = self._data.pop(key, default)
         return ret
 
     def popitem(self):  # noqa: D102
-        with self._load_and_save():
+        with self._load_and_save:
             ret = self._data.popitem()
         return ret
 
     def clear(self):  # noqa: D102
         self._data = {}
-        with self._thread_lock():
+        with self._thread_lock:
             self._save()
 
     def update(self, other=None, **kwargs):  # noqa: D102
@@ -251,13 +251,13 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         else:
             other = {}
 
-        with self._load_and_save():
+        with self._load_and_save:
             # The order here is important to ensure that the promised sequence of
             # overrides is obeyed: kwargs > other > existing data.
             self._update({**self._data, **other, **kwargs})
 
     def setdefault(self, key, default=None):  # noqa: D102
-        with self._load_and_save():
+        with self._load_and_save:
             if key in self._data:
                 ret = self._data[key]
             else:
@@ -270,11 +270,38 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
                 # looping over data.
                 data = {key: ret}
                 self._validate(data)
-                with self._suspend_sync():
+                with self._suspend_sync:
                     for key, value in data.items():
                         self._data[key] = value
         return ret
 
+    @property
+    def _protected_keys(self):
+        """Get the protected keys of this class.
+
+        The :class:`SyncedAttrDict` overrides the default getattr and setattr
+        methods to provide attribute-based access to dictionary elements. The
+        cost of this feature is that internal attribute access is also piped
+        through this logic and there is no easy way around it. To circumvent
+        this problem, classes can specify the ``_PROTECTED_KEYS`` attribute to
+        indicate what attributes should be set as actual attributes rather than
+        being added as dict elements. Since subclasses may add additional such
+        attributes to the ones specified by this class, this property provides
+        a centralized means by which all the protected keys associated with a
+        given class can be accessed by accumulated all of the protected keys of
+        all the classes in the inheritance hierarchy.
+        """
+        try:
+            return type(self)._all_protected_keys
+        except AttributeError:
+            protected_keys = set()
+            # Classes inherit the protected attributes of their parent classes.
+            for base_cls in type(self).__mro__:
+                if hasattr(base_cls, "_PROTECTED_KEYS"):
+                    protected_keys.update(base_cls._PROTECTED_KEYS)
+            type(self)._all_protected_keys = protected_keys
+            return protected_keys
+
     def __getattr__(self, name):
         if name.startswith("__"):
             raise AttributeError(f"'SyncedAttrDict' object has no attribute '{name}'")
@@ -289,13 +316,13 @@ class SyncedAttrDict(SyncedCollection, MutableMapping):
         except AttributeError:
             super().__setattr__(key, value)
         else:
-            if key.startswith("__") or key in self._PROTECTED_KEYS:
+            if key.startswith("__") or key in self._protected_keys:
                 super().__setattr__(key, value)
             else:
                 self.__setitem__(key, value)
 
     def __delattr__(self, key):
-        if key.startswith("__") or key in self._PROTECTED_KEYS:
+        if key.startswith("__") or key in self._protected_keys:
             super().__delattr__(key)
         else:
             self.__delitem__(key)
diff --git a/signac/core/synced_collections/synced_collection.py b/signac/core/synced_collections/synced_collection.py
index 5cc9394a..56a0c176 100644
--- a/signac/core/synced_collections/synced_collection.py
+++ b/signac/core/synced_collections/synced_collection.py
@@ -5,12 +5,11 @@
 from abc import abstractmethod
 from collections import defaultdict
 from collections.abc import Collection
-from contextlib import contextmanager
 from inspect import isabstract
 from threading import RLock
 from typing import Any, Callable, DefaultDict, List
 
-from .utils import AbstractTypeResolver
+from .utils import AbstractTypeResolver, _CounterContext, _NullContext
 
 try:
     import numpy
@@ -28,25 +27,26 @@ _sc_resolver = AbstractTypeResolver(
 )
 
 
-class _fake_lock:
-    """A nullary context manager to simulate a lock in backends that don't support them."""
+class _LoadAndSave:
+    """A context manager for :class:`SyncedCollection` to wrap saving and loading.
 
-    def __enter__(self):
-        pass
-
-    def __exit__(self, type, value, traceback):
-        pass
+    Any write operation on a synced collection must be preceded by a load and
+    followed by a save. Moreover, additional logic may be required to handle
+    other aspects of the synchronization, particularly the acquisition of thread
+    locks. This class abstracts this concept, making it easy for subclasses to
+    customize the behavior if needed (for instance, to introduce additional locks).
+    """
 
+    def __init__(self, collection):
+        self._collection = collection
 
-@contextmanager
-def _thread_lock(self):
-    """Prepare context for thread-safe operation.
+    def __enter__(self):
+        self._collection._thread_lock.__enter__()
+        self._collection._load()
 
-    All operations that can mutate an object should use this context
-    manager to ensure thread safety.
-    """
-    with type(self)._locks[self._lock_id]:
-        yield
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._collection._save()
+        self._collection._thread_lock.__exit__(exc_type, exc_val, exc_tb)
 
 
 class SyncedCollection(Collection):
@@ -128,10 +128,24 @@ class SyncedCollection(Collection):
     _validators: List[Callable] = []
     # Backends that support threading should modify this flag.
     _supports_threading: bool = False
+    _LoadSaveType = _LoadAndSave
 
     def __init__(self, parent=None, *args, **kwargs):
-        self._parent = parent
-        self._suspend_sync_ = 0
+        # Nested collections need to know their root collection, which is
+        # responsible for all synchronization, and therefore all the associated
+        # context managers are also stored from the root.
+        if parent is not None:
+            self._root = parent._root if parent._root is not None else parent
+        else:
+            self._root = parent
+        self._suspend_sync = (
+            _CounterContext() if self._root is None else self._root._suspend_sync
+        )
+        self._load_and_save = (
+            type(self)._LoadSaveType(self)
+            if self._root is None
+            else self._root._load_and_save
+        )
         if type(self)._supports_threading:
             type(self)._locks[self._lock_id] = RLock()
 
@@ -160,20 +174,6 @@ class SyncedCollection(Collection):
         else:
             cls.disable_multithreading()
 
-    @contextmanager
-    def _load_and_save(self):
-        """Prepare a context manager in which mutating changes can happen.
-
-        Various standard operations on this data structure require loading the data,
-        modifying it, and then saving it back. This context manager encapsulates that
-        simple requirement, and it provides a hook for subclasses that require
-        additional logic in these operations.
-        """
-        with self._thread_lock():
-            self._load()
-            yield
-            self._save()
-
     @classmethod
     def enable_multithreading(cls):
         """Allow multithreaded access to and modification of :class:`SyncedCollection`s.
@@ -183,6 +183,16 @@ class SyncedCollection(Collection):
 
         """
         if cls._supports_threading:
+
+            @property
+            def _thread_lock(self):
+                """Get the lock specific to this collection.
+
+                Since locks support the context manager protocol, this method
+                can typically be invoked directly as part of a ``with`` statement.
+                """
+                return type(self)._locks[self._lock_id]
+
             cls._thread_lock = _thread_lock
             cls._threading_support_is_active = True
         else:
@@ -196,7 +206,7 @@ class SyncedCollection(Collection):
         costs, so they can be disabled for classes that support it.
 
         """
-        cls._thread_lock = _fake_lock
+        cls._thread_lock = _NullContext()
         cls._threading_support_is_active = False
 
     @property
@@ -298,13 +308,6 @@ class SyncedCollection(Collection):
         """
         pass
 
-    @contextmanager
-    def _suspend_sync(self):
-        """Prepare context where synchronization is suspended."""
-        self._suspend_sync_ += 1
-        yield
-        self._suspend_sync_ -= 1
-
     @classmethod
     @abstractmethod
     def is_base_type(cls, data):
@@ -355,11 +358,11 @@ class SyncedCollection(Collection):
         handles the appropriate recursive calls, then farms out the actual writing
         to the abstract method :meth:`~._save_to_resource`.
         """
-        if self._suspend_sync_ <= 0:
-            if self._parent is None:
+        if not self._suspend_sync:
+            if self._root is None:
                 self._save_to_resource()
             else:
-                self._parent._save()
+                self._root._save()
 
     @abstractmethod
     def _update(self, data):
@@ -392,13 +395,13 @@ class SyncedCollection(Collection):
         handles the appropriate recursive calls, then farms out the actual reading
         to the abstract method :meth:`~._load_from_resource`.
         """
-        if self._suspend_sync_ <= 0:
-            if self._parent is None:
+        if not self._suspend_sync:
+            if self._root is None:
                 data = self._load_from_resource()
-                with self._suspend_sync():
+                with self._suspend_sync:
                     self._update(data)
             else:
-                self._parent._load()
+                self._root._load()
 
     def _validate(self, data):
         """Validate the input data.
@@ -435,13 +438,13 @@ class SyncedCollection(Collection):
             (Default value = None).
 
         """
-        all_parent = all([arg is not None for arg in resource_args.values()])
-        any_parent = any([arg is not None for arg in resource_args.values()])
+        all_root = all([arg is not None for arg in resource_args.values()])
+        any_root = any([arg is not None for arg in resource_args.values()])
 
         all_nested = (data is not None) and (parent is not None)
         any_nested = (data is not None) or (parent is not None)
 
-        if not ((all_parent and not any_nested) or (all_nested and not any_parent)):
+        if not ((all_root and not any_nested) or (all_nested and not any_root)):
             raise ValueError(
                 f"A {type(self)} must either be synchronized, in which case "
                 f"the arguments ({', '.join(resource_args.keys())}) must be "
@@ -460,7 +463,7 @@ class SyncedCollection(Collection):
         return self._data[key]
 
     def __delitem__(self, item):
-        with self._load_and_save():
+        with self._load_and_save:
             del self._data[item]
 
     def __iter__(self):
diff --git a/signac/core/synced_collections/synced_list.py b/signac/core/synced_collections/synced_list.py
index 4d00721c..16a8b251 100644
--- a/signac/core/synced_collections/synced_list.py
+++ b/signac/core/synced_collections/synced_list.py
@@ -55,11 +55,11 @@ class SyncedList(SyncedCollection, MutableSequence):
             self._validate(data)
             if NUMPY and isinstance(data, numpy.ndarray):
                 data = data.tolist()
-            with self._suspend_sync():
+            with self._suspend_sync:
                 self._data = [
                     self._from_base(data=value, parent=self) for value in data
                 ]
-            with self._thread_lock():
+            with self._thread_lock:
                 self._save()
 
     @classmethod
@@ -118,7 +118,7 @@ class SyncedList(SyncedCollection, MutableSequence):
         if data is None:
             self._data.clear()
         elif _sequence_resolver.get_type(data) == "SEQUENCE":
-            with self._suspend_sync():
+            with self._suspend_sync:
                 # This loop is optimized based on common usage patterns:
                 # insertion and removal at the end of a list. Inserting or
                 # removing in the middle will result in extra conversion
@@ -170,11 +170,11 @@ class SyncedList(SyncedCollection, MutableSequence):
             data = data.tolist()
         self._validate(data)
         if _sequence_resolver.get_type(data) == "SEQUENCE":
-            with self._suspend_sync():
+            with self._suspend_sync:
                 self._data = [
                     self._from_base(data=value, parent=self) for value in data
                 ]
-            with self._thread_lock():
+            with self._thread_lock:
                 self._save()
         else:
             raise ValueError(
@@ -185,9 +185,8 @@ class SyncedList(SyncedCollection, MutableSequence):
 
     def __setitem__(self, key, value):
         self._validate(value)
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data[key] = self._from_base(data=value, parent=self)
+        with self._load_and_save, self._suspend_sync:
+            self._data[key] = self._from_base(data=value, parent=self)
 
     def __reversed__(self):
         self._load()
@@ -197,44 +196,36 @@ class SyncedList(SyncedCollection, MutableSequence):
         # Convert input to a list so that iterators work as well as iterables.
         iterable_data = list(iterable)
         self._validate(iterable_data)
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data += [
-                    self._from_base(data=value, parent=self) for value in iterable_data
-                ]
+        with self._load_and_save, self._suspend_sync:
+            self._data += [
+                self._from_base(data=value, parent=self) for value in iterable_data
+            ]
         return self
 
     def insert(self, index, item):  # noqa: D102
         self._validate(item)
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data.insert(index, self._from_base(data=item, parent=self))
+        with self._load_and_save, self._suspend_sync:
+            self._data.insert(index, self._from_base(data=item, parent=self))
 
     def append(self, item):  # noqa: D102
         self._validate(item)
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data.append(self._from_base(data=item, parent=self))
+        with self._load_and_save, self._suspend_sync:
+            self._data.append(self._from_base(data=item, parent=self))
 
     def extend(self, iterable):  # noqa: D102
         # Convert iterable to a list to ensure generators are exhausted only once
         iterable_data = list(iterable)
         self._validate(iterable_data)
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data.extend(
-                    [
-                        self._from_base(data=value, parent=self)
-                        for value in iterable_data
-                    ]
-                )
+        with self._load_and_save, self._suspend_sync:
+            self._data.extend(
+                [self._from_base(data=value, parent=self) for value in iterable_data]
+            )
 
     def remove(self, value):  # noqa: D102
-        with self._load_and_save():
-            with self._suspend_sync():
-                self._data.remove(self._from_base(data=value, parent=self))
+        with self._load_and_save, self._suspend_sync:
+            self._data.remove(self._from_base(data=value, parent=self))
 
     def clear(self):  # noqa: D102
         self._data = []
-        with self._thread_lock():
+        with self._thread_lock:
             self._save()
diff --git a/signac/core/synced_collections/utils.py b/signac/core/synced_collections/utils.py
index b0a7a324..882171c6 100644
--- a/signac/core/synced_collections/utils.py
+++ b/signac/core/synced_collections/utils.py
@@ -141,3 +141,72 @@ class SCJSONEncoder(JSONEncoder):
             # Call the super method, which raises a TypeError if it cannot
             # encode the object.
             return super().default(o)
+
+
+class _NullContext:
+    """A nullary context manager.
+
+    There are various cases where we sometimes want to perform a task within a
+    particular context, but at other times we wish to ignore that context. The
+    most obvious example is a lock for threading: since
+    :class:`SyncedCollection` allows multithreading support to be enabled or
+    disabled, it is important to be able to write code that is agnostic to
+    whether or not a mutex must be acquired prior to executing a task. Locks
+    support the context manager protocol and are used in that manner throughout
+    the code base, so the most transparent way to disable buffering is to
+    create a nullary context manager that can be placed as a drop-in
+    replacement for the lock so that all other code can handle this in a
+    transparent manner.
+    """
+
+    def __enter__(self):
+        pass
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        pass
+
+    def __call__(self):
+        """Allow usage of the context in a function-like manner."""
+        return self
+
+
+class _CounterContext:
+    """A context manager that maintains a total entry count.
+
+    This class simply contains an internal counter that is incremented on every
+    entrance and decremented on every exit. It is also truthy and only evaluates
+    to True if the count is greater than 0.
+    """
+
+    def __init__(self):
+        self._count = 0
+
+    def __enter__(self):
+        self._count += 1
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._count -= 1
+
+    def __bool__(self):
+        return self._count > 0
+
+    def __call__(self):
+        """Allow usage of the context in a function-like manner."""
+        return self
+
+
+class _CounterFuncContext(_CounterContext):
+    """A counter that performs some operation whenever the counter hits zero.
+
+    This class maintains a counter, and also accepts an arbitrary nullary
+    callable to be executed anytime the context exits and the counter hits zero.
+    """
+
+    def __init__(self, func):
+        super().__init__()
+        self._func = func
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        super().__exit__(exc_type, exc_val, exc_tb)
+        if not self:
+            self._func()


Test patch:
diff --git a/tests/test_synced_collections/synced_collection_test.py b/tests/test_synced_collections/synced_collection_test.py
index 7ba6d56b..c8fc5ee6 100644
--- a/tests/test_synced_collections/synced_collection_test.py
+++ b/tests/test_synced_collections/synced_collection_test.py
@@ -246,9 +246,9 @@ class SyncedDictTest(SyncedCollectionTest):
 
         # deleting a protected attribute
         synced_collection._load()
-        del synced_collection._parent
-        # deleting _parent will lead to recursion as _parent is treated as key
-        # _load() will check for _parent and __getattr__ will call __getitem__ which calls _load()
+        del synced_collection._root
+        # deleting _root will lead to recursion as _root is treated as key
+        # _load() will check for _root and __getattr__ will call __getitem__ which calls _load()
         with pytest.raises(RecursionError):
             synced_collection._load()
 
diff --git a/tests/test_synced_collections/test_json_buffered_collection.py b/tests/test_synced_collections/test_json_buffered_collection.py
index 9e0bc976..937d40a0 100644
--- a/tests/test_synced_collections/test_json_buffered_collection.py
+++ b/tests/test_synced_collections/test_json_buffered_collection.py
@@ -53,7 +53,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         synced_collection["buffered"] = testdata
         assert "buffered" in synced_collection
         assert synced_collection["buffered"] == testdata
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             assert "buffered" in synced_collection
             assert synced_collection["buffered"] == testdata
             synced_collection["buffered2"] = 1
@@ -62,7 +62,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         assert len(synced_collection) == 2
         assert "buffered2" in synced_collection
         assert synced_collection["buffered2"] == 1
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             del synced_collection["buffered"]
             assert len(synced_collection) == 1
             assert "buffered" not in synced_collection
@@ -73,7 +73,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
 
         # Explicitly check that the file has not been changed when buffering.
         raw_dict = synced_collection()
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection["buffered3"] = 1
             on_disk_dict = self.load(synced_collection)
             assert "buffered3" not in on_disk_dict
@@ -89,7 +89,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         synced_collection2 = self._collection_type(filename=synced_collection._filename)
 
         # Check that the non-buffered object is not modified.
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection["buffered2"] = 1
             assert "buffered2" not in synced_collection2
 
@@ -100,7 +100,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
 
         # Check that the non-buffered object is not modified.
         with pytest.raises(MetadataError):
-            with synced_collection.buffered():
+            with synced_collection.buffered:
                 synced_collection["buffered2"] = 1
                 synced_collection2["buffered2"] = 2
                 assert synced_collection["buffered2"] == 1
@@ -114,7 +114,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         synced_collection2 = self._collection_type(filename=synced_collection._filename)
 
         # Check that the non-buffered object is not modified.
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection2["buffered2"] = 1
             assert "buffered2" in synced_collection
             synced_collection["buffered2"] = 3
@@ -159,8 +159,8 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
             [synced_collection.buffered, buffer_all], repeat=2
         ):
             err_msg = (
-                f"outer_buffer: {outer_buffer.__qualname__}, "
-                f"inner_buffer: {inner_buffer.__qualname__}"
+                f"outer_buffer: {type(outer_buffer).__qualname__}, "
+                f"inner_buffer: {type(inner_buffer).__qualname__}"
             )
             synced_collection.reset({"outside": 1})
             with outer_buffer():
@@ -183,7 +183,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
 
         synced_collection["outside"] = 1
         synced_collection2["outside"] = 1
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection["inside_first"] = 2
             on_disk_dict = self.load(synced_collection)
             assert "inside_first" in synced_collection
@@ -235,7 +235,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         assert len(synced_collection2) == 0
 
         synced_collection["outside"] = 1
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection["inside_first"] = 2
 
             on_disk_dict = self.load(synced_collection)
@@ -265,7 +265,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         synced_collection["outside"] = 1
         finished = False
         with pytest.raises(MetadataError):
-            with synced_collection.buffered():
+            with synced_collection.buffered:
                 synced_collection["inside_first"] = 2
                 # Modifying synced_collection2 here causes problems. It is
                 # unbuffered, so it directly writes to file.  Then, when
@@ -315,7 +315,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
             assert len(synced_collection) == 0
             synced_collection.clear()
 
-            with synced_collection.buffered():
+            with synced_collection.buffered:
                 synced_collection["foo"] = 1
                 assert self._collection_type.get_current_buffer_size() == len(
                     repr(synced_collection)
@@ -514,7 +514,7 @@ class TestBufferedJSONDict(BufferedJSONCollectionTest, TestJSONDict):
         del sc
 
         sc = self._collection_type(fn, write_concern)
-        with sc.buffered():
+        with sc.buffered:
             sc["foo"] = 3
 
         assert "bar" in sc
@@ -529,7 +529,7 @@ class TestBufferedJSONList(BufferedJSONCollectionTest, TestJSONList):
         synced_collection.extend([1, 2, 3])
         assert len(synced_collection) == 3
         assert synced_collection == [1, 2, 3]
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             assert len(synced_collection) == 3
             assert synced_collection == [1, 2, 3]
             synced_collection[0] = 4
@@ -537,7 +537,7 @@ class TestBufferedJSONList(BufferedJSONCollectionTest, TestJSONList):
             assert synced_collection == [4, 2, 3]
         assert len(synced_collection) == 3
         assert synced_collection == [4, 2, 3]
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             assert len(synced_collection) == 3
             assert synced_collection == [4, 2, 3]
             del synced_collection[0]
@@ -548,7 +548,7 @@ class TestBufferedJSONList(BufferedJSONCollectionTest, TestJSONList):
 
         # Explicitly check that the file has not been changed when buffering.
         raw_list = synced_collection()
-        with synced_collection.buffered():
+        with synced_collection.buffered:
             synced_collection.append(10)
             on_disk_list = self.load(synced_collection)
             assert 10 not in on_disk_list