Use the lock to protect shared mutable state during mutations, but avoid holding it for read-side convenience/field-by-field getters.

Rules

  1. Lock only for writes: Acquire the mutex only around code that mutates shared collections/fields (e.g., logs[], updates[], notifications[]).
  2. Minimize critical sections: Do expensive or deterministic work (masking, truncation, formatting) outside the lock; lock only to commit the final updates.
  3. Reads: prefer no lock or atomic snapshots:

Example pattern (commit under lock, transform outside)

def update_item(self, *, no: int, heading: str | None, content: str | None):
    # Preprocess outside the lock
    if heading is not None:
        new_heading = _truncate_heading(self._mask_recursive(heading))
    if content is not None:
        item_type = self.logs[no].type  # or pass in required type explicitly
        new_content = _truncate_content(self._mask_recursive(content), item_type)

    with self._lock:
        item = self.logs[no]
        if heading is not None:
            item.heading = new_heading
        if content is not None:
            item.content = new_content
        self.updates.append(item.no)

Applying this standard will reduce lock contention, prevent unnecessary refactors tied to read-side locking, and still keep shared writes thread-safe.