diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 96190a7f5821709..df14c48e18d874e 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -644,6 +644,7 @@ def test_join(self): self.assertEqual(self.type2test(b"").join(lst), b"abc") self.assertEqual(self.type2test(b"").join(tuple(lst)), b"abc") self.assertEqual(self.type2test(b"").join(iter(lst)), b"abc") + self.assertEqual(self.type2test(b".").join([b"ab", b"cd"]), b"ab.cd") dot_join = self.type2test(b".:").join self.assertEqual(dot_join([b"ab", b"cd"]), b"ab.:cd") self.assertEqual(dot_join([memoryview(b"ab"), b"cd"]), b"ab.:cd") @@ -654,6 +655,8 @@ def test_join(self): seq = [b"abc"] * 100000 expected = b"abc" + b".:abc" * 99999 self.assertEqual(dot_join(seq), expected) + seq = [bytearray(b"abc")] * 100000 + self.assertEqual(dot_join(seq), expected) # Stress test with empty separator seq = [b"abc"] * 100000 expected = b"abc" * 100000 @@ -671,12 +674,13 @@ def test_join_concurrent_buffer_mutation(self): # mutate the joined sequence (simulated here by mutating in __buffer__). # See: https://github.com/python/cpython/issues/151295 def make_seq(mutate): - # Item is only referenced from the list slot, so mutate() frees it. + # The first two items are only referenced from their list slots, + # so mutate() frees them. class Item: def __buffer__(self, flags): mutate(seq) return memoryview(b'x') - seq = [b'a', Item(), b'c'] + seq = [bytes(2), Item(), b'c'] return seq for sep in (self.type2test(b''), self.type2test(b'::')): @@ -686,11 +690,11 @@ def __buffer__(self, flags): self.assertRaises(RuntimeError, sep.join, seq) # The list length is unchanged, so the size-change recheck - # cannot fire: only keeping the item alive avoids the crash. + # cannot fire: only keeping the items alive avoids the crash. def replace(seq): - seq[1] = b'z' + seq[0] = seq[1] = b'z' seq = make_seq(replace) - self.assertEqual(sep.join(seq), sep.join([b'a', b'x', b'c'])) + self.assertEqual(sep.join(seq), sep.join([bytes(2), b'x', b'c'])) def test_count(self): b = self.type2test(b'mississippi') diff --git a/Lib/test/test_free_threading/test_bytes_object.py b/Lib/test/test_free_threading/test_bytes_object.py index a9017bac6de0295..666805a5d93626f 100644 --- a/Lib/test/test_free_threading/test_bytes_object.py +++ b/Lib/test/test_free_threading/test_bytes_object.py @@ -54,6 +54,27 @@ def reader(): threading_helper.run_concurrently([writer] + [reader] * 4) + def test_racing_join_replace_large(self): + # join() suspends the critical section while it copies a result + # of 1 MiB or more, so the list can drop its items meanwhile. + size = 1 << 18 + lst = [bytes(size) for _ in range(8)] + done = Event() + + def writer(): + try: + for _ in range(100): + for i in range(len(lst)): + lst[i] = bytes(size) + finally: + done.set() + + def reader(): + while not done.is_set(): + self.assertEqual(b''.join(lst), bytes(size * len(lst))) + + threading_helper.run_concurrently([writer] + [reader] * 4) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-08-17-36-53.gh-issue-159191.4YpkIS.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-08-17-36-53.gh-issue-159191.4YpkIS.rst new file mode 100644 index 000000000000000..60f2a95ca5f3435 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-08-17-36-53.gh-issue-159191.4YpkIS.rst @@ -0,0 +1,3 @@ +Speed up :meth:`bytes.join` and :meth:`bytearray.join` when all items are +:class:`bytes`. Patch by Pieter Eendebak and Christian Aurich Zanettini +Martins. diff --git a/Objects/stringlib/join.h b/Objects/stringlib/join.h index 4764baac181b2ad..a2067212db94863 100644 --- a/Objects/stringlib/join.h +++ b/Objects/stringlib/join.h @@ -34,6 +34,44 @@ STRINGLIB(bytes_join_lock_held)(PyObject *sep, PyObject *seq) } } #endif + + /* Fast path: all items are exact bytes and the result is too small to + * release the GIL for. Nothing here runs Python code or suspends the + * critical section, so seq keeps the items alive and they can be copied + * without taking references. */ + Py_ssize_t limit = GIL_THRESHOLD - seplen; + for (i = 0; i < seqlen; i++) { + item = PySequence_Fast_GET_ITEM(seq, i); + if (!PyBytes_CheckExact(item) + || PyBytes_GET_SIZE(item) >= limit - sz) { + break; + } + sz += PyBytes_GET_SIZE(item) + seplen; + } + if (i == seqlen) { + res = STRINGLIB_NEW(NULL, sz - seplen); + if (res == NULL) { + return NULL; + } + p = STRINGLIB_STR(res); + for (i = 0; i < seqlen; i++) { + if (i != 0 && seplen != 0) { + if (seplen == 1) { + *p++ = *sepstr; + } + else { + memcpy(p, sepstr, seplen); + p += seplen; + } + } + item = PySequence_Fast_GET_ITEM(seq, i); + memcpy(p, PyBytes_AS_STRING(item), PyBytes_GET_SIZE(item)); + p += PyBytes_GET_SIZE(item); + } + return res; + } + sz = 0; + if (seqlen > NB_STATIC_BUFFERS) { buffers = PyMem_NEW(Py_buffer, seqlen); if (buffers == NULL) { @@ -148,8 +186,16 @@ STRINGLIB(bytes_join_lock_held)(PyObject *sep, PyObject *seq) error: res = NULL; done: - for (i = 0; i < nbufs; i++) - PyBuffer_Release(&buffers[i]); + for (i = 0; i < nbufs; i++) { + PyObject *obj = buffers[i].obj; + if (obj != NULL && PyBytes_CheckExact(obj)) { + /* bytes has no bf_releasebuffer */ + Py_DECREF(obj); + } + else { + PyBuffer_Release(&buffers[i]); + } + } if (buffers != static_buffers) PyMem_Free(buffers); return res;