Skip to content

Commit 837627d

Browse files
xzmengMeng Xiangzhuoserhiy-storchaka
authored
gh-108518: Do not cancel remaining calls in Executor.map() on error (GH-109497)
If a call raises an exception, the remaining calls are no longer cancelled and iteration can be continued. Use the close() method of the returned iterator to cancel them. Co-authored-by: Meng Xiangzhuo <aumo@foxmail.com> Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
1 parent f3be08a commit 837627d

6 files changed

Lines changed: 96 additions & 13 deletions

File tree

Doc/library/concurrent.futures.rst

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,18 @@ Executor Objects
6161
The returned iterator raises a :exc:`TimeoutError`
6262
if :meth:`~iterator.__next__` is called and the result isn't available
6363
after *timeout* seconds from the original call to :meth:`Executor.map`.
64-
*timeout* can be an int or a float. If *timeout* is not specified or
64+
*timeout* can be an int or a float.
65+
It cancels all future calls of *fn* and closes the iterator.
66+
If *timeout* is not specified or
6567
``None``, there is no limit to the wait time.
6668

6769
If a *fn* call raises an exception, then that exception will be
6870
raised when its value is retrieved from the iterator.
71+
It does not cancel future calls of *fn*.
72+
73+
The returned iterator has method :meth:`!close` which cancels all
74+
future calls of *fn* and discards the results of already finished calls
75+
if they are available.
6976

7077
When using :class:`ProcessPoolExecutor`, this method chops *iterables*
7178
into a number of chunks which it submits to the pool as separate
@@ -82,6 +89,10 @@ Executor Objects
8289
.. versionchanged:: 3.14
8390
Added the *buffersize* parameter.
8491

92+
.. versionchanged:: next
93+
The returned iterator is no longer automatically closed if a *fn*
94+
call raises an exception.
95+
8596
.. method:: shutdown(wait=True, *, cancel_futures=False)
8697

8798
Signal the executor that it should free any resources that it is using

Doc/whatsnew/3.16.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,15 @@ ctypes
276276
(Contributed by Peter Bierma in :gh:`153903`.)
277277

278278

279+
concurrent.futures
280+
------------------
281+
282+
* The iterator returned by :meth:`concurrent.futures.Executor.map` is no longer
283+
automatically closed if a function call raises an exception.
284+
Use method :meth:`!close` to explicitly close the iterator.
285+
(Contributed by xzmeng and Serhiy Storchaka in :gh:`108518`.)
286+
287+
279288
encodings
280289
---------
281290

Lib/concurrent/futures/_base.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,11 @@ def wait(fs, timeout=None, return_when=ALL_COMPLETED):
309309
def _result_or_cancel(fut, timeout=None):
310310
try:
311311
try:
312-
return fut.result(timeout)
312+
return (fut.result(timeout), None)
313+
except TimeoutError:
314+
raise
315+
except BaseException as exc:
316+
return (None, exc)
313317
finally:
314318
fut.cancel()
315319
finally:
@@ -592,6 +596,7 @@ def _get_snapshot(self):
592596

593597
__class_getitem__ = classmethod(types.GenericAlias)
594598

599+
595600
class Executor(object):
596601
"""This is an abstract base class for concrete asynchronous executors."""
597602

@@ -638,7 +643,10 @@ def map(self, fn, *iterables, timeout=None, chunksize=1, buffersize=None):
638643
raise TypeError("buffersize must be an integer or None")
639644
if buffersize is not None and buffersize < 1:
640645
raise ValueError("buffersize must be None or > 0")
646+
return _MapResultIterator(self._map(fn, *iterables, timeout=timeout,
647+
buffersize=buffersize))
641648

649+
def _map(self, fn, *iterables, timeout=None, buffersize=None):
642650
if timeout is not None:
643651
end_time = timeout + time.monotonic()
644652

@@ -701,6 +709,24 @@ def __exit__(self, exc_type, exc_val, exc_tb):
701709
return False
702710

703711

712+
class _MapResultIterator:
713+
"""The iterator returned by map()."""
714+
def __init__(self, gen):
715+
self.gen = gen
716+
717+
def __iter__(self):
718+
return self
719+
720+
def __next__(self):
721+
value, exc = next(self.gen)
722+
if exc is not None:
723+
raise exc
724+
return value
725+
726+
def close(self):
727+
self.gen.close()
728+
729+
704730
class BrokenExecutor(RuntimeError):
705731
"""
706732
Raised when an executor has become non-functional after a severe failure.

Lib/concurrent/futures/process.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,14 @@ def _process_chunk(fn, chunk):
200200
This function is run in a separate process.
201201
202202
"""
203-
return [fn(*args) for args in chunk]
203+
results = []
204+
for args in chunk:
205+
try:
206+
result = (fn(*args), None)
207+
except BaseException as exc:
208+
result = (None, exc)
209+
results.append(result)
210+
return results
204211

205212

206213
def _sendback_result(result_queue, work_id, result=None, exception=None,
@@ -963,7 +970,7 @@ def map(self, fn, *iterables, timeout=None, chunksize=1, buffersize=None):
963970
itertools.batched(zip(*iterables), chunksize),
964971
timeout=timeout,
965972
buffersize=buffersize)
966-
return _chain_from_iterable_of_lists(results)
973+
return _base._MapResultIterator(_chain_from_iterable_of_lists(results))
967974

968975
def shutdown(self, wait=True, *, cancel_futures=False):
969976
with self._shutdown_lock:

Lib/test/test_concurrent_futures/executor.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,21 +71,30 @@ def test_map(self):
7171

7272
@warnings_helper.ignore_fork_in_thread_deprecation_warnings()
7373
def test_map_exception(self):
74-
i = self.executor.map(divmod, [1, 1, 1, 1], [2, 3, 0, 5])
75-
self.assertEqual(i.__next__(), (0, 1))
76-
self.assertEqual(i.__next__(), (0, 1))
77-
with self.assertRaises(ZeroDivisionError):
78-
i.__next__()
74+
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 3, 0, 5])
75+
self.assertEqual(next(i), (2, 1))
76+
self.assertEqual(next(i), (1, 2))
77+
self.assertRaises(ZeroDivisionError, next, i)
78+
self.assertEqual(next(i), (1, 0))
79+
self.assertRaises(StopIteration, next, i)
80+
self.assertRaises(StopIteration, next, i)
81+
82+
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5], chunksize=3)
83+
self.assertEqual(next(i), (2, 1))
84+
self.assertRaises(ZeroDivisionError, next, i)
85+
self.assertEqual(next(i), (1, 2))
86+
self.assertEqual(next(i), (1, 0))
87+
self.assertRaises(StopIteration, next, i)
88+
self.assertRaises(StopIteration, next, i)
7989

8090
@warnings_helper.ignore_fork_in_thread_deprecation_warnings()
8191
@support.requires_resource('walltime')
8292
def test_map_timeout(self):
8393
results = []
94+
i = self.executor.map(time.sleep, [0, 0, 6], timeout=5)
8495
try:
85-
for i in self.executor.map(time.sleep,
86-
[0, 0, 6],
87-
timeout=5):
88-
results.append(i)
96+
for result in i:
97+
results.append(result)
8998
except futures.TimeoutError:
9099
pass
91100
else:
@@ -95,6 +104,24 @@ def test_map_timeout(self):
95104
# take longer than the specified timeout.
96105
self.assertIn(results, ([None, None], [None], []))
97106

107+
# The remaining calls are cancelled, so the iterator is exhausted.
108+
self.assertRaises(StopIteration, next, i)
109+
self.assertRaises(StopIteration, next, i)
110+
111+
@warnings_helper.ignore_fork_in_thread_deprecation_warnings()
112+
def test_map_close(self):
113+
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5])
114+
self.assertEqual(next(i), (2, 1))
115+
i.close()
116+
self.assertRaises(StopIteration, next, i)
117+
self.assertRaises(StopIteration, next, i)
118+
119+
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5], chunksize=3)
120+
self.assertEqual(next(i), (2, 1))
121+
i.close()
122+
self.assertRaises(StopIteration, next, i)
123+
self.assertRaises(StopIteration, next, i)
124+
98125
def test_map_buffersize_type_validation(self):
99126
for buffersize in ("foo", 2.0):
100127
with self.subTest(buffersize=buffersize):
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The iterator returned by :meth:`concurrent.futures.Executor.map` is no longer
2+
automatically closed if a function call raises an exception.
3+
Use method :meth:`!close` to explicitly close the iterator.

0 commit comments

Comments
 (0)