Skip to content

gh-108518: Make concurrent.futures.Executor.map() consistent with built-in map() #109497

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Doc/library/concurrent.futures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,18 @@ Executor Objects
The returned iterator raises a :exc:`TimeoutError`
if :meth:`~iterator.__next__` is called and the result isn't available
after *timeout* seconds from the original call to :meth:`Executor.map`.
*timeout* can be an int or a float. If *timeout* is not specified or
*timeout* can be an int or a float.
It cancels all future calls of *fn* and closes the iterator.
If *timeout* is not specified or
``None``, there is no limit to the wait time.

If a *fn* call raises an exception, then that exception will be
raised when its value is retrieved from the iterator.
It does not cancel future calls of *fn*.

The returned iterator has method :meth:`!close` which cancels all
future calls of *fn* and discards the results of already finished calls
if they are available.

When using :class:`ProcessPoolExecutor`, this method chops *iterables*
into a number of chunks which it submits to the pool as separate
Expand All @@ -68,6 +75,10 @@ Executor Objects
.. versionchanged:: 3.5
Added the *chunksize* argument.

.. versionchanged:: 3.13
The returned iterator no longer automatically closed if a *fn* call
raises an exception.

.. method:: shutdown(wait=True, *, cancel_futures=False)

Signal the executor that it should free any resources that it is using
Expand Down
5 changes: 5 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ Other Language Changes
(Contributed by Levi Sabah, Zackery Spytz and Hugo van Kemenade in
:gh:`73965`.)

* The iterator returned by :meth:`concurrent.futures.Executor.map` is no longer
automatically closed if a function call raises an exception.
Use method :meth:`!close` to explicitly close the iterator.
(Contributed by xzmeng and Serhiy Storchaka in :gh:`108518`.)

New Modules
===========

Expand Down
28 changes: 27 additions & 1 deletion Lib/concurrent/futures/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,11 @@ def wait(fs, timeout=None, return_when=ALL_COMPLETED):
def _result_or_cancel(fut, timeout=None):
try:
try:
return fut.result(timeout)
return (fut.result(timeout), None)
except TimeoutError:
raise
except BaseException as exc:
return (None, exc)
finally:
fut.cancel()
finally:
Expand Down Expand Up @@ -566,6 +570,7 @@ def set_exception(self, exception):

__class_getitem__ = classmethod(types.GenericAlias)


class Executor(object):
"""This is an abstract base class for concrete asynchronous executors."""

Expand Down Expand Up @@ -602,6 +607,9 @@ def map(self, fn, *iterables, timeout=None, chunksize=1):
before the given timeout.
Exception: If fn(*args) raises for any values.
"""
return _MapResultIterator(self._map(fn, *iterables, timeout=timeout))

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

Expand Down Expand Up @@ -648,6 +656,24 @@ def __exit__(self, exc_type, exc_val, exc_tb):
return False


class _MapResultIterator:
"""The iterator returned by map()."""
def __init__(self, gen):
self.gen = gen

def __iter__(self):
return self

def __next__(self):
value, exc = next(self.gen)
if exc is not None:
raise exc
return value

def close(self):
self.gen.close()


class BrokenExecutor(RuntimeError):
"""
Raised when a executor has become non-functional after a severe failure.
Expand Down
11 changes: 9 additions & 2 deletions Lib/concurrent/futures/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,14 @@ def _process_chunk(fn, chunk):
This function is run in a separate process.

"""
return [fn(*args) for args in chunk]
results = []
for args in chunk:
try:
result = (fn(*args), None)
except BaseException as exc:
result = (None, exc)
results.append(result)
return results


def _sendback_result(result_queue, work_id, result=None, exception=None,
Expand Down Expand Up @@ -839,7 +846,7 @@ def map(self, fn, *iterables, timeout=None, chunksize=1):
results = super().map(partial(_process_chunk, fn),
itertools.batched(zip(*iterables), chunksize),
timeout=timeout)
return _chain_from_iterable_of_lists(results)
return _base._MapResultIterator(_chain_from_iterable_of_lists(results))

def shutdown(self, wait=True, *, cancel_futures=False):
with self._shutdown_lock:
Expand Down
50 changes: 34 additions & 16 deletions Lib/test/test_concurrent_futures/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,25 +48,43 @@ def test_map(self):
list(map(pow, range(10), range(10))))

def test_map_exception(self):
i = self.executor.map(divmod, [1, 1, 1, 1], [2, 3, 0, 5])
self.assertEqual(i.__next__(), (0, 1))
self.assertEqual(i.__next__(), (0, 1))
self.assertRaises(ZeroDivisionError, i.__next__)
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 3, 0, 5])
self.assertEqual(next(i), (2, 1))
self.assertEqual(next(i), (1, 2))
self.assertRaises(ZeroDivisionError, next, i)
self.assertEqual(next(i), (1, 0))
self.assertRaises(StopIteration, next, i)
self.assertRaises(StopIteration, next, i)

i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5], chunksize=3)
self.assertEqual(next(i), (2, 1))
self.assertRaises(ZeroDivisionError, next, i)
self.assertEqual(next(i), (1, 2))
self.assertEqual(next(i), (1, 0))
self.assertRaises(StopIteration, next, i)
self.assertRaises(StopIteration, next, i)

@support.requires_resource('walltime')
def test_map_timeout(self):
results = []
try:
for i in self.executor.map(time.sleep,
[0, 0, 6],
timeout=5):
results.append(i)
except futures.TimeoutError:
pass
else:
self.fail('expected TimeoutError')

self.assertEqual([None, None], results)
i = self.executor.map(time.sleep, [0, 0, 6, 0], timeout=5)
next(i)
next(i)
self.assertRaises(futures.TimeoutError, next, i)
self.assertRaises(StopIteration, next, i)
self.assertRaises(StopIteration, next, i)

def test_map_close(self):
i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5])
self.assertEqual(next(i), (2, 1))
i.close()
self.assertRaises(StopIteration, next, i)
self.assertRaises(StopIteration, next, i)

i = self.executor.map(divmod, [5, 5, 5, 5], [2, 0, 3, 5], chunksize=3)
self.assertEqual(next(i), (2, 1))
i.close()
self.assertRaises(StopIteration, next, i)
self.assertRaises(StopIteration, next, i)

def test_shutdown_race_issue12456(self):
# Issue #12456: race condition at shutdown where trying to post a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The iterator returned by :meth:`concurrent.futures.Executor.map` is no longer
automatically closed if a function call raises an exception.
Use method :meth:`!close` to explicitly close the iterator.
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy