Movatterモバイル変換


[0]ホーム

URL:


Following system colour schemeSelected dark colour schemeSelected light colour scheme

Python Enhancement Proposals

PEP 550 – Execution Context

Author:
Yury Selivanov <yury at edgedb.com>,Elvis Pranskevichus <elvis at edgedb.com>
Status:
Withdrawn
Type:
Standards Track
Created:
11-Aug-2017
Python-Version:
3.7
Post-History:
11-Aug-2017, 15-Aug-2017, 18-Aug-2017, 25-Aug-2017,01-Sep-2017

Table of Contents

Abstract

This PEP adds a new generic mechanism of ensuring consistent accessto non-local state in the context of out-of-order execution, suchas in Python generators and coroutines.

Thread-local storage, such asthreading.local(), is inadequate forprograms that execute concurrently in the same OS thread. This PEPproposes a solution to this problem.

PEP Status

Due to its breadth and the lack of general consensus on some aspects, thisPEP has been withdrawn and superseded by a simplerPEP 567, which hasbeen accepted and included in Python 3.7.

PEP 567 implements the same core idea, but limits the ContextVar supportto asynchronous tasks while leaving the generator behavior untouched.The latter may be revisited in a future PEP.

Rationale

Prior to the advent of asynchronous programming in Python, programsused OS threads to achieve concurrency. The need for thread-specificstate was solved bythreading.local() and its C-API equivalent,PyThreadState_GetDict().

A few examples of where Thread-local storage (TLS) is commonlyrelied upon:

  • Context managers like decimal contexts,numpy.errstate,andwarnings.catch_warnings.
  • Request-related data, such as security tokens and requestdata in web applications, language context forgettext etc.
  • Profiling, tracing, and logging in large code bases.

Unfortunately, TLS does not work well for programs which executeconcurrently in a single thread. A Python generator is the simplestexample of a concurrent program. Consider the following:

deffractions(precision,x,y):withdecimal.localcontext()asctx:ctx.prec=precisionyieldDecimal(x)/Decimal(y)yieldDecimal(x)/Decimal(y**2)g1=fractions(precision=2,x=1,y=3)g2=fractions(precision=6,x=2,y=3)items=list(zip(g1,g2))

The intuitively expected value ofitems is:

[(Decimal('0.33'),Decimal('0.666667')),(Decimal('0.11'),Decimal('0.222222'))]

Rather surprisingly, the actual result is:

[(Decimal('0.33'),Decimal('0.666667')),(Decimal('0.111111'),Decimal('0.222222'))]

This is because implicit Decimal context is stored as a thread-local,so concurrent iteration of thefractions() generator wouldcorrupt the state. For Decimal, specifically, the only currentworkaround is to use explicit context method calls for all arithmeticoperations[28]. Arguably, this defeats the usefulness of overloadedoperators and makes even simple formulas hard to read and write.

Coroutines are another class of Python code where TLS unreliabilityis a significant issue.

The inadequacy of TLS in asynchronous code has lead to theproliferation of ad-hoc solutions, which are limited in scope anddo not support all required use cases.

The current status quo is that any library (including the standardlibrary), which relies on TLS, is likely to be broken when used inasynchronous code or with generators (see[3] as an example issue.)

Some languages, that support coroutines or generators, recommendpassing the context manually as an argument to every function, see[1] for an example. This approach, however, has limited use forPython, where there is a large ecosystem that was built to work witha TLS-like context. Furthermore, libraries likedecimal ornumpy rely on context implicitly in overloaded operatorimplementations.

The .NET runtime, which has support for async/await, has a genericsolution for this problem, calledExecutionContext (see[2]).

Goals

The goal of this PEP is to provide a more reliablethreading.local() alternative, which:

  • provides the mechanism and the API to fix non-local state issueswith coroutines and generators;
  • implements TLS-like semantics for synchronous code, so thatusers likedecimal andnumpy can switch to the newmechanism with minimal risk of breaking backwards compatibility;
  • has no or negligible performance impact on the existing code orthe code that will be using the new mechanism, includingC extensions.

High-Level Specification

The full specification of this PEP is broken down into three parts:

  • High-Level Specification (this section): the description of theoverall solution. We show how it applies to generators andcoroutines in user code, without delving into implementationdetails.
  • Detailed Specification: the complete description of new concepts,APIs, and related changes to the standard library.
  • Implementation Details: the description and analysis of datastructures and algorithms used to implement this PEP, as well asthe necessary changes to CPython.

For the purpose of this section, we defineexecution context as anopaque container of non-local state that allows consistent access toits contents in the concurrent execution environment.

Acontext variable is an object representing a value in theexecution context. A call tocontextvars.ContextVar(name)creates a new context variable object. A context variable object hasthree methods:

  • get(): returns the value of the variable in the currentexecution context;
  • set(value): sets the value of the variable in the currentexecution context;
  • delete(): can be used for restoring variable state, it’spurpose and semantics are explained inSetting and restoring context variables.

Regular Single-threaded Code

In regular, single-threaded code that doesn’t involve generators orcoroutines, context variables behave like globals:

var=contextvars.ContextVar('var')defsub():assertvar.get()=='main'var.set('sub')defmain():var.set('main')sub()assertvar.get()=='sub'

Multithreaded Code

In multithreaded code, context variables behave like thread locals:

var=contextvars.ContextVar('var')defsub():assertvar.get()isNone# The execution context is empty# for each new thread.var.set('sub')defmain():var.set('main')thread=threading.Thread(target=sub)thread.start()thread.join()assertvar.get()=='main'

Generators

Unlike regular function calls, generators can cooperatively yieldtheir control of execution to the caller. Furthermore, a generatordoes not controlwhere the execution would continue after it yields.It may be resumed from an arbitrary code location.

For these reasons, the least surprising behaviour of generators isas follows:

  • changes to context variables are always local and are not visiblein the outer context, but are visible to the code called by thegenerator;
  • once set in the generator, the context variable is guaranteed notto change between iterations;
  • changes to context variables in outer context (where the generatoris being iterated) are visible to the generator, unless thesevariables were also modified inside the generator.

Let’s review:

var1=contextvars.ContextVar('var1')var2=contextvars.ContextVar('var2')defgen():var1.set('gen')assertvar1.get()=='gen'assertvar2.get()=='main'yield1# Modification to var1 in main() is shielded by# gen()'s local modification.assertvar1.get()=='gen'# But modifications to var2 are visibleassertvar2.get()=='main modified'yield2defmain():g=gen()var1.set('main')var2.set('main')next(g)# Modification of var1 in gen() is not visible.assertvar1.get()=='main'var1.set('main modified')var2.set('main modified')next(g)

Now, let’s revisit the decimal precision example from theRationalesection, and see how the execution context can improve the situation:

importdecimal# create a new context vardecimal_ctx=contextvars.ContextVar('decimal context')# Pre-PEP 550 Decimal relies on TLS for its context.# For illustration purposes, we monkey-patch the decimal# context functions to use the execution context.# A real working fix would need to properly update the# C implementation as well.defpatched_setcontext(context):decimal_ctx.set(context)defpatched_getcontext():ctx=decimal_ctx.get()ifctxisNone:ctx=decimal.Context()decimal_ctx.set(ctx)returnctxdecimal.setcontext=patched_setcontextdecimal.getcontext=patched_getcontextdeffractions(precision,x,y):withdecimal.localcontext()asctx:ctx.prec=precisionyieldMyDecimal(x)/MyDecimal(y)yieldMyDecimal(x)/MyDecimal(y**2)g1=fractions(precision=2,x=1,y=3)g2=fractions(precision=6,x=2,y=3)items=list(zip(g1,g2))

The value ofitems is:

[(Decimal('0.33'),Decimal('0.666667')),(Decimal('0.11'),Decimal('0.222222'))]

which matches the expected result.

Coroutines and Asynchronous Tasks

Like generators, coroutines can yield and regain control. The majordifference from generators is that coroutines do not yield to theimmediate caller. Instead, the entire coroutine call stack(coroutines chained byawait) switches to another coroutine callstack. In this regard,await-ing on a coroutine is conceptuallysimilar to a regular function call, and a coroutine chain(or a “task”, e.g. anasyncio.Task) is conceptually similar to athread.

From this similarity we conclude that context variables in coroutinesshould behave like “task locals”:

  • changes to context variables in a coroutine are visible to thecoroutine that awaits on it;
  • changes to context variables made in the caller prior to awaitingare visible to the awaited coroutine;
  • changes to context variables made in one task are not visible inother tasks;
  • tasks spawned by other tasks inherit the execution context from theparent task, but any changes to context variables made in theparent taskafter the child task was spawned arenot visible.

The last point shows behaviour that is different from OS threads.OS threads do not inherit the execution context by default.There are two reasons for this:common usage intent and backwardscompatibility.

The main reason for why tasks inherit the context, and threads donot, is the common usage intent. Tasks are often used for relativelyshort-running operations which are logically tied to the code thatspawned the task (like running a coroutine with a timeout inasyncio). OS threads, on the other hand, are normally used forlong-running, logically separate code.

With respect to backwards compatibility, we want the execution contextto behave likethreading.local(). This is so that libraries canstart using the execution context in place of TLS with a lesser riskof breaking compatibility with existing code.

Let’s review a few examples to illustrate the semantics we have justdefined.

Context variable propagation in a single task:

importasynciovar=contextvars.ContextVar('var')asyncdefmain():var.set('main')awaitsub()# The effect of sub() is visible.assertvar.get()=='sub'asyncdefsub():assertvar.get()=='main'var.set('sub')assertvar.get()=='sub'loop=asyncio.get_event_loop()loop.run_until_complete(main())

Context variable propagation between tasks:

importasynciovar=contextvars.ContextVar('var')asyncdefmain():var.set('main')loop.create_task(sub())# schedules asynchronous execution# of sub().assertvar.get()=='main'var.set('main changed')asyncdefsub():# Sleeping will make sub() run after# "var" is modified in main().awaitasyncio.sleep(1)# The value of "var" is inherited from main(), but any# changes to "var" made in main() after the task# was created are *not* visible.assertvar.get()=='main'# This change is local to sub() and will not be visible# to other tasks, including main().var.set('sub')loop=asyncio.get_event_loop()loop.run_until_complete(main())

As shown above, changes to the execution context are local to thetask, and tasks get a snapshot of the execution context at the pointof creation.

There is one narrow edge case when this can lead to surprisingbehaviour. Consider the following example where we modify thecontext variable in a nested coroutine:

asyncdefsub(var_value):awaitasyncio.sleep(1)var.set(var_value)asyncdefmain():var.set('main')# waiting for sub() directlyawaitsub('sub-1')# var change is visibleassertvar.get()=='sub-1'# waiting for sub() with a timeout;awaitasyncio.wait_for(sub('sub-2'),timeout=2)# wait_for() creates an implicit task, which isolates# context changes, which means that the below assertion# will fail.assertvar.get()=='sub-2'#  AssertionError!

However, relying on context changes leaking to the caller isultimately a bad pattern. For this reason, the behaviour shown inthe above example is not considered a major issue and can beaddressed with proper documentation.

Detailed Specification

Conceptually, anexecution context (EC) is a stack of logicalcontexts. There is always exactly one active EC per Python thread.

Alogical context (LC) is a mapping of context variables to theirvalues in that particular LC.

Acontext variable is an object representing a value in theexecution context. A new context variable object is created bycallingcontextvars.ContextVar(name:str). The value of therequiredname argument is not used by the EC machinery, but maybe used for debugging and introspection.

The context variable object has the following methods and attributes:

  • name: the value passed toContextVar().
  • get(*,topmost=False,default=None), iftopmost isFalse(the default), traverses the execution context top-to-bottom, untilthe variable value is found. Iftopmost isTrue, returnsthe value of the variable in the topmost logical context.If the variable value was not found, returns the value ofdefault.
  • set(value): sets the value of the variable in the topmostlogical context.
  • delete(): removes the variable from the topmost logical context.Useful when restoring the logical context to the state prior to theset() call, for example, in a context manager, seeSetting and restoring context variables for more information.

Generators

When created, each generator object has an empty logical contextobject stored in its__logical_context__ attribute. This logicalcontext is pushed onto the execution context at the beginning of eachgenerator iteration and popped at the end:

var1=contextvars.ContextVar('var1')var2=contextvars.ContextVar('var2')defgen():var1.set('var1-gen')var2.set('var2-gen')# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})# ]n=nested_gen()# nested_gen_LC is creatednext(n)# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen', var2: 'var2-gen'})# ]var1.set('var1-gen-mod')var2.set('var2-gen-mod')# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'})# ]next(n)defnested_gen():# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),#     nested_gen_LC()# ]assertvar1.get()=='var1-gen'assertvar2.get()=='var2-gen'var1.set('var1-nested-gen')# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen', var2: 'var2-gen'}),#     nested_gen_LC({var1: 'var1-nested-gen'})# ]yield# EC = [#     outer_LC(),#     gen_LC({var1: 'var1-gen-mod', var2: 'var2-gen-mod'}),#     nested_gen_LC({var1: 'var1-nested-gen'})# ]assertvar1.get()=='var1-nested-gen'assertvar2.get()=='var2-gen-mod'yield# EC = [outer_LC()]g=gen()# gen_LC is created for the generator object `g`list(g)# EC = [outer_LC()]

The snippet above shows the state of the execution context stackthroughout the generator lifespan.

contextlib.contextmanager

Thecontextlib.contextmanager() decorator can be used to turna generator into a context manager. A context manager thattemporarily modifies the value of a context variable could be definedlike this:

var=contextvars.ContextVar('var')@contextlib.contextmanagerdefvar_context(value):original_value=var.get()try:var.set(value)yieldfinally:var.set(original_value)

Unfortunately, this would not work straight away, as the modificationto thevar variable is contained to thevar_context()generator, and therefore will not be visible inside thewithblock:

deffunc():# EC = [{}, {}]withvar_context(10):# EC becomes [{}, {}, {var: 10}] in the# *precision_context()* generator,# but here the EC is still [{}, {}]assertvar.get()==10# AssertionError!

The way to fix this is to set the generator’s__logical_context__attribute toNone. This will cause the generator to avoidmodifying the execution context stack.

We modify thecontextlib.contextmanager() decorator tosetgenobj.__logical_context__ toNone to producewell-behaved context managers:

deffunc():# EC = [{}, {}]withvar_context(10):# EC = [{}, {var: 10}]assertvar.get()==10# EC becomes [{}, {var: None}]

Enumerating context vars

TheExecutionContext.vars() method returns a list ofContextVar objects, that have values in the execution context.This method is mostly useful for introspection and logging.

coroutines

In CPython, coroutines share the implementation with generators.The difference is that in coroutines__logical_context__ defaultstoNone. This affects both theasyncdef coroutines and theold-style generator-based coroutines (generators decorated with@types.coroutine).

Asynchronous Generators

The execution context semantics in asynchronous generators does notdiffer from that of regular generators.

asyncio

asyncio usesLoop.call_soon,Loop.call_later,andLoop.call_at to schedule the asynchronous execution of afunction.asyncio.Task usescall_soon() to run thewrapped coroutine.

We modifyLoop.call_{at,later,soon} to accept the newoptionalexecution_context keyword argument, which defaults tothe copy of the current execution context:

defcall_soon(self,callback,*args,execution_context=None):ifexecution_contextisNone:execution_context=contextvars.get_execution_context()# ... some time latercontextvars.run_with_execution_context(execution_context,callback,args)

Thecontextvars.get_execution_context() function returns ashallow copy of the current execution context. By shallow copy herewe mean such a new execution context that:

  • lookups in the copy provide the same results as in the originalexecution context, and
  • any changes in the original execution context do not affect thecopy, and
  • any changes to the copy do not affect the original executioncontext.

Either of the following satisfy the copy requirements:

  • a new stack with shallow copies of logical contexts;
  • a new stack with one squashed logical context.

Thecontextvars.run_with_execution_context(ec,func,*args,**kwargs) function runsfunc(*args,**kwargs) withec as theexecution context. The function performs the following steps:

  1. Setec as the current execution context stack in the currentthread.
  2. Push an empty logical context onto the stack.
  3. Runfunc(*args,**kwargs).
  4. Pop the logical context from the stack.
  5. Restore the original execution context stack.
  6. Return or raise thefunc() result.

These steps ensure thatec cannot be modified byfunc,which makesrun_with_execution_context() idempotent.

asyncio.Task is modified as follows:

classTask:def__init__(self,coro):...# Get the current execution context snapshot.self._exec_context=contextvars.get_execution_context()# Create an empty Logical Context that will be# used by coroutines run in the task.coro.__logical_context__=contextvars.LogicalContext()self._loop.call_soon(self._step,execution_context=self._exec_context)def_step(self,exc=None):...self._loop.call_soon(self._step,execution_context=self._exec_context)...

Generators Transformed into Iterators

Any Python generator can be represented as an equivalent iterator.Compilers like Cython rely on this axiom. With respect to theexecution context, such iterator should behave the same way as thegenerator it represents.

This means that there needs to be a Python API to create new logicalcontexts and run code with a given logical context.

Thecontextvars.LogicalContext() function creates a new emptylogical context.

Thecontextvars.run_with_logical_context(lc,func,*args,**kwargs) function can be used to run functions in the specifiedlogical context. Thelc can be modified as a result of the call.

Thecontextvars.run_with_logical_context() function performs thefollowing steps:

  1. Pushlc onto the current execution context stack.
  2. Runfunc(*args,**kwargs).
  3. Poplc from the execution context stack.
  4. Return or raise thefunc() result.

By usingLogicalContext() andrun_with_logical_context(),we can replicate the generator behaviour like this:

classGenerator:def__init__(self):self.logical_context=contextvars.LogicalContext()def__iter__(self):returnselfdef__next__(self):returncontextvars.run_with_logical_context(self.logical_context,self._next_impl)def_next_impl(self):# Actual __next__ implementation....

Let’s see how this pattern can be applied to an example generator:

# create a new context variablevar=contextvars.ContextVar('var')defgen_series(n):var.set(10)foriinrange(1,n):yieldvar.get()*i# gen_series is equivalent to the following iterator:classCompiledGenSeries:# This class is what the `gen_series()` generator can# be transformed to by a compiler like Cython.def__init__(self,n):# Create a new empty logical context,# like the generators do.self.logical_context=contextvars.LogicalContext()# Initialize the generator in its LC.# Otherwise `var.set(10)` in the `_init` method# would leak.contextvars.run_with_logical_context(self.logical_context,self._init,n)def_init(self,n):self.i=1self.n=nvar.set(10)def__iter__(self):returnselfdef__next__(self):# Run the actual implementation of __next__ in our LC.returncontextvars.run_with_logical_context(self.logical_context,self._next_impl)def_next_impl(self):ifself.i==self.n:raiseStopIterationresult=var.get()*self.iself.i+=1returnresult

For hand-written iterators such approach to context management isnormally not necessary, and it is easier to set and restorecontext variables directly in__next__:

classMyIterator:# ...def__next__(self):old_val=var.get()try:var.set(new_val)# ...finally:var.set(old_val)

Implementation

Execution context is implemented as an immutable linked list oflogical contexts, where each logical context is an immutable weak keymapping. A pointer to the currently active execution context isstored in the OS thread state:

                  +-----------------+                  |                 |     ec                  |  PyThreadState  +-------------+                  |                 |             |                  +-----------------+             |                                                  |ec_node             ec_node             ec_node   v+------+------+     +------+------+     +------+------+| NULL |  lc  |<----| prev |  lc  |<----| prev |  lc  |+------+--+---+     +------+--+---+     +------+--+---+          |                   |                   |LC        v         LC        v         LC        v+-------------+     +-------------+     +-------------+| var1: obj1  |     |    EMPTY    |     | var1: obj4  || var2: obj2  |     +-------------+     +-------------+| var3: obj3  |+-------------+

The choice of the immutable list of immutable mappings as afundamental data structure is motivated by the need to efficientlyimplementcontextvars.get_execution_context(), which is to befrequently used by asynchronous tasks and callbacks. When the EC isimmutable,get_execution_context() can simply copy the currentexecution contextby reference:

defget_execution_context(self):returnPyThreadState_Get().ec

Let’s review all possible context modification scenarios:

  • TheContextVariable.set() method is called:
    defContextVar_set(self,val):# See a more complete set() definition# in the `Context Variables` section.tstate=PyThreadState_Get()top_ec_node=tstate.ectop_lc=top_ec_node.lcnew_top_lc=top_lc.set(self,val)tstate.ec=ec_node(prev=top_ec_node.prev,lc=new_top_lc)
  • Thecontextvars.run_with_logical_context() is called, in whichcase the passed logical context object is appended to the executioncontext:
    defrun_with_logical_context(lc,func,*args,**kwargs):tstate=PyThreadState_Get()old_top_ec_node=tstate.ecnew_top_ec_node=ec_node(prev=old_top_ec_node,lc=lc)try:tstate.ec=new_top_ec_nodereturnfunc(*args,**kwargs)finally:tstate.ec=old_top_ec_node
  • Thecontextvars.run_with_execution_context() is called, in whichcase the current execution context is set to the passed executioncontext with a new empty logical context appended to it:
    defrun_with_execution_context(ec,func,*args,**kwargs):tstate=PyThreadState_Get()old_top_ec_node=tstate.ecnew_lc=contextvars.LogicalContext()new_top_ec_node=ec_node(prev=ec,lc=new_lc)try:tstate.ec=new_top_ec_nodereturnfunc(*args,**kwargs)finally:tstate.ec=old_top_ec_node
  • Eithergenobj.send(),genobj.throw(),genobj.close()are called on agenobj generator, in which case the logicalcontext recorded ingenobj is pushed onto the stack:
    PyGen_New(PyGenObject*gen):if(gen.gi_code.co_flags&(CO_COROUTINE|CO_ITERABLE_COROUTINE)):# gen is an 'async def' coroutine, or a generator# decorated with @types.coroutine.gen.__logical_context__=Noneelse:# Non-coroutine generatorgen.__logical_context__=contextvars.LogicalContext()gen_send(PyGenObject*gen,...):tstate=PyThreadState_Get()ifgen.__logical_context__isnotNone:old_top_ec_node=tstate.ecnew_top_ec_node=ec_node(prev=old_top_ec_node,lc=gen.__logical_context__)try:tstate.ec=new_top_ec_nodereturn_gen_send_impl(gen,...)finally:gen.__logical_context__=tstate.ec.lctstate.ec=old_top_ec_nodeelse:return_gen_send_impl(gen,...)
  • Coroutines and asynchronous generators share the implementationwith generators, and the above changes apply to them as well.

In certain scenarios the EC may need to be squashed to limit thesize of the chain. For example, consider the following corner case:

asyncdefrepeat(coro,delay):awaitcoro()awaitasyncio.sleep(delay)loop.create_task(repeat(coro,delay))asyncdefping():print('ping')loop=asyncio.get_event_loop()loop.create_task(repeat(ping,1))loop.run_forever()

In the above code, the EC chain will grow as long asrepeat() iscalled. Each new task will callcontextvars.run_with_execution_context(), which will append a newlogical context to the chain. To prevent unbounded growth,contextvars.get_execution_context() checks if the chainis longer than a predetermined maximum, and if it is, squashes thechain into a single LC:

defget_execution_context():tstate=PyThreadState_Get()iftstate.ec_len>EC_LEN_MAX:squashed_lc=contextvars.LogicalContext()ec_node=tstate.ecwhileec_node:# The LC.merge() method does not replace# existing keys.squashed_lc=squashed_lc.merge(ec_node.lc)ec_node=ec_node.prevreturnec_node(prev=NULL,lc=squashed_lc)else:returntstate.ec

Logical Context

Logical context is an immutable weak key mapping which has thefollowing properties with respect to garbage collection:

  • ContextVar objects are strongly-referenced only from theapplication code, not from any of the execution context machineryor values they point to. This means that there are no referencecycles that could extend their lifespan longer than necessary, orprevent their collection by the GC.
  • Values put in the execution context are guaranteed to be keptalive while there is aContextVar key referencing them inthe thread.
  • If aContextVar is garbage collected, all of its values willbe removed from all contexts, allowing them to be GCed if needed.
  • If an OS thread has ended its execution, its thread state will becleaned up along with its execution context, cleaningup all values bound to all context variables in the thread.

As discussed earlier, we needcontextvars.get_execution_context()to be consistently fast regardless of the size of the executioncontext, so logical context is necessarily an immutable mapping.

Choosingdict for the underlying implementation is suboptimal,becauseLC.set() will causedict.copy(), which is an O(N)operation, whereN is the number of items in the LC.

get_execution_context(), when squashing the EC, is an O(M)operation, whereM is the total number of context variable valuesin the EC.

So, instead ofdict, we choose Hash Array Mapped Trie (HAMT)as the underlying implementation of logical contexts. (Scala andClojure use HAMT to implement high performance immutable collections[5],[6].)

With HAMT.set() becomes an O(log N) operation, andget_execution_context() squashing is more efficient on average dueto structural sharing in HAMT.

SeeAppendix: HAMT Performance Analysis for a more elaborateanalysis of HAMT performance compared todict.

Context Variables

TheContextVar.get() andContextVar.set() methods areimplemented as follows (in pseudo-code):

classContextVar:defget(self,*,default=None,topmost=False):tstate=PyThreadState_Get()ec_node=tstate.ecwhileec_node:ifselfinec_node.lc:returnec_node.lc[self]iftopmost:breakec_node=ec_node.prevreturndefaultdefset(self,value):tstate=PyThreadState_Get()top_ec_node=tstate.eciftop_ec_nodeisnotNone:top_lc=top_ec_node.lcnew_top_lc=top_lc.set(self,value)tstate.ec=ec_node(prev=top_ec_node.prev,lc=new_top_lc)else:# First ContextVar.set() in this OS thread.top_lc=contextvars.LogicalContext()new_top_lc=top_lc.set(self,value)tstate.ec=ec_node(prev=NULL,lc=new_top_lc)defdelete(self):tstate=PyThreadState_Get()top_ec_node=tstate.eciftop_ec_nodeisNone:raiseLookupErrortop_lc=top_ec_node.lcifselfnotintop_lc:raiseLookupErrornew_top_lc=top_lc.delete(self)tstate.ec=ec_node(prev=top_ec_node.prev,lc=new_top_lc)

For efficient access in performance-sensitive code paths, such as innumpy anddecimal, we cache lookups inContextVar.get(),making it an O(1) operation when the cache is hit. The cache key iscomposed from the following:

  • The newuint64_tPyThreadState->unique_id, which is a globallyunique thread state identifier. It is computed from the newuint64_tPyInterpreterState->ts_counter, which is incrementedwhenever a new thread state is created.
  • The newuint64_tPyThreadState->stack_version, which is athread-specific counter, which is incremented whenever a non-emptylogical context is pushed onto the stack or popped from the stack.
  • Theuint64_tContextVar->version counter, which is incrementedwhenever the context variable value is changed in any logicalcontext in any OS thread.

The cache is then implemented as follows:

classContextVar:defset(self,value):...# implementationself.version+=1defget(self,*,default=None,topmost=False):iftopmost:returnself._get_uncached(default=default,topmost=topmost)tstate=PyThreadState_Get()if(self.last_tstate_id==tstate.unique_idandself.last_stack_ver==tstate.stack_versionandself.last_version==self.version):returnself.last_valuevalue=self._get_uncached(default=default)self.last_value=value# borrowed refself.last_tstate_id=tstate.unique_idself.last_stack_version=tstate.stack_versionself.last_version=self.versionreturnvalue

Note thatlast_value is a borrowed reference. We assume thatif the version checks are fine, the value object will be alive.This allows the values of context variables to be properly garbagecollected.

This generic caching approach is similar to what the current Cimplementation ofdecimal does to cache the current decimalcontext, and has similar performance characteristics.

Performance Considerations

Tests of the reference implementation based on the priorrevisions of this PEP have shown 1-2% slowdown on generatormicrobenchmarks and no noticeable difference in macrobenchmarks.

The performance of non-generator and non-async code is notaffected by this PEP.

Summary of the New APIs

Python

The following new Python APIs are introduced by this PEP:

  1. The newcontextvars.ContextVar(name:str='...') class,instances of which have the following:
    • the read-only.name attribute,
    • the.get() method, which returns the value of the variablein the current execution context;
    • the.set() method, which sets the value of the variable inthe current logical context;
    • the.delete() method, which removes the value of the variablefrom the current logical context.
  2. The newcontextvars.ExecutionContext() class, which representsan execution context.
  3. The newcontextvars.LogicalContext() class, which representsa logical context.
  4. The newcontextvars.get_execution_context() function, whichreturns anExecutionContext instance representing a copy ofthe current execution context.
  5. Thecontextvars.run_with_execution_context(ec:ExecutionContext,func,*args,**kwargs) function, which runsfunc with theprovided execution context.
  6. Thecontextvars.run_with_logical_context(lc:LogicalContext,func,*args,**kwargs) function, which runsfunc with theprovided logical context on top of the current execution context.

C API

  1. PyContextVar*PyContext_NewVar(char*desc): create aPyContextVar object.
  2. PyObject*PyContext_GetValue(PyContextVar*,inttopmost):return the value of the variable in the current execution context.
  3. intPyContext_SetValue(PyContextVar*,PyObject*): setthe value of the variable in the current logical context.
  4. intPyContext_DelValue(PyContextVar*): delete the value ofthe variable from the current logical context.
  5. PyLogicalContext*PyLogicalContext_New(): create a new emptyPyLogicalContext.
  6. PyExecutionContext*PyExecutionContext_New(): create a newemptyPyExecutionContext.
  7. PyExecutionContext*PyExecutionContext_Get(): return thecurrent execution context.
  8. intPyContext_SetCurrent(PyExecutionContext*,PyLogicalContext*): set thepassed EC object as the current execution context for the activethread state, and/or set the passed LC object as the currentlogical context.

Design Considerations

Should “yield from” leak context changes?

No. It may be argued thatyieldfrom is semanticallyequivalent to calling a function, and should leak context changes.However, it is not possible to satisfy the following at the same time:

  • next(gen)does not leak context changes made ingen, and
  • yieldfromgenleaks context changes made ingen.

The reason is thatyieldfrom can be used with a partiallyiterated generator, which already has local context changes:

var=contextvars.ContextVar('var')defgen():foriinrange(10):var.set('gen')yieldidefouter_gen():var.set('outer_gen')g=gen()yieldnext(g)# Changes not visible during partial iteration,# the goal of this PEP:assertvar.get()=='outer_gen'yield fromgassertvar.get()=='outer_gen'# or 'gen'?

Another example would be refactoring of an explicitfor..inyieldconstruct to ayieldfrom expression. Consider the followingcode:

defouter_gen():var.set('outer_gen')foriingen():yieldiassertvar.get()=='outer_gen'

which we want to refactor to useyieldfrom:

defouter_gen():var.set('outer_gen')yield fromgen()assertvar.get()=='outer_gen'# or 'gen'?

The above examples illustrate that it is unsafe to refactorgenerator code usingyieldfrom when it can leak context changes.

Thus, the only well-defined and consistent behaviour is toalways isolate context changes in generators, regardless ofhow they are being iterated.

ShouldPyThreadState_GetDict() use the execution context?

No.PyThreadState_GetDict is based on TLS, and changing itssemantics will break backwards compatibility.

PEP 521

PEP 521 proposes an alternative solution to the problem, whichextends the context manager protocol with two new methods:__suspend__() and__resume__(). Similarly, the asynchronouscontext manager protocol is also extended with__asuspend__() and__aresume__().

This allows implementing context managers that manage non-local state,which behave correctly in generators and coroutines.

For example, consider the following context manager, which usesexecution state:

classContext:def__init__(self):self.var=contextvars.ContextVar('var')def__enter__(self):self.old_x=self.var.get()self.var.set('something')def__exit__(self,*err):self.var.set(self.old_x)

An equivalent implementation withPEP 521:

local=threading.local()classContext:def__enter__(self):self.old_x=getattr(local,'x',None)local.x='something'def__suspend__(self):local.x=self.old_xdef__resume__(self):local.x='something'def__exit__(self,*err):local.x=self.old_x

The downside of this approach is the addition of significant newcomplexity to the context manager protocol and the interpreterimplementation. This approach is also likely to negatively impactthe performance of generators and coroutines.

Additionally, the solution inPEP 521 is limited to contextmanagers, and does not provide any mechanism to propagate state inasynchronous tasks and callbacks.

Can Execution Context be implemented without modifying CPython?

No.

It is true that the concept of “task-locals” can be implementedfor coroutines in libraries (see, for example,[29] and[30]).On the other hand, generators are managed by the Python interpreterdirectly, and so their context must also be managed by theinterpreter.

Furthermore, execution context cannot be implemented in a third-partymodule at all, otherwise the standard library, includingdecimalwould not be able to rely on it.

Should we update sys.displayhook and other APIs to use EC?

APIs like redirecting stdout by overwritingsys.stdout, orspecifying new exception display hooks by overwriting thesys.displayhook function are affecting the whole Python processby design. Their users assume that the effect of changingthem will be visible across OS threads. Therefore, we cannotjust make these APIs to use the new Execution Context.

That said we think it is possible to design new APIs that willbe context aware, but that is outside of the scope of this PEP.

Greenlets

Greenlet is an alternative implementation of cooperativescheduling for Python. Although greenlet package is not part ofCPython, popular frameworks like gevent rely on it, and it isimportant that greenlet can be modified to support executioncontexts.

Conceptually, the behaviour of greenlets is very similar to that ofgenerators, which means that similar changes around greenlet entryand exit can be done to add support for execution context. ThisPEP provides the necessary C APIs to do that.

Context manager as the interface for modifications

This PEP concentrates on the low-level mechanics and the minimalAPI that enables fundamental operations with execution context.

For developer convenience, a high-level context manager interfacemay be added to thecontextvars module. For example:

withcontextvars.set_var(var,'foo'):# ...

Setting and restoring context variables

TheContextVar.delete() method removes the context variable fromthe topmost logical context.

If the variable is not found in the topmost logical context, aLookupError is raised, similarly todelvar raisingNameError whenvar is not in scope.

This method is useful when there is a (rare) need to correctly restorethe state of a logical context, such as when a nested generatorwants to modify the logical contexttemporarily:

var=contextvars.ContextVar('var')defgen():withsome_var_context_manager('gen'):# EC = [{var: 'main'}, {var: 'gen'}]assertvar.get()=='gen'yield# EC = [{var: 'main modified'}, {}]assertvar.get()=='main modified'yielddefmain():var.set('main')g=gen()next(g)var.set('main modified')next(g)

The above example would work correctly only if there is a way todeletevar from the logical context ingen(). Setting itto a “previous value” in__exit__() would mask changes madeinmain() between the iterations.

Alternative Designs for ContextVar API

Logical Context with stacked values

By the design presented in this PEP, logical context is a simpleLC({ContextVar:value,...}) mapping. An alternativerepresentation is to store a stack of values for each contextvariable:LC({ContextVar:[val1,val2,...],...}).

TheContextVar methods would then be:

  • get(*,default=None) – traverses the stackof logical contexts, and returns the top value from thefirst non-empty logical context;
  • push(val) – pushesval onto the stack of values in thecurrent logical context;
  • pop() – pops the top value from the stack of values inthe current logical context.

Compared to the single-value design with theset() anddelete() methods, the stack-based approach allows for a simplerimplementation of the set/restore pattern. However, the mentalburden of this approach is considered to be higher, since therewould betwo stacks to consider: a stack of LCs and a stack ofvalues in each LC.

(This idea was suggested by Nathaniel Smith.)

ContextVar “set/reset”

Yet another approach is to return a special object fromContextVar.set(), which would represent the modification ofthe context variable in the current logical context:

var=contextvars.ContextVar('var')deffoo():mod=var.set('spam')# ... perform workmod.reset()# Reset the value of var to the original value# or remove it from the context.

The critical flaw in this approach is that it becomes possible topass context var “modification objects” into code running in adifferent execution context, which leads to undefined side effects.

Backwards Compatibility

This proposal preserves 100% backwards compatibility.

Rejected Ideas

Replication of threading.local() interface

Choosing thethreading.local()-like interface for contextvariables was considered and rejected for the following reasons:

  • A survey of the standard library and Django has shown that thevast majority ofthreading.local() uses involve a singleattribute, which indicates that the namespace approach is notas helpful in the field.
  • Using__getattr__() instead of.get() for value lookupdoes not provide any way to specify the depth of the lookup(i.e. search only the top logical context).
  • Single-valueContextVar is easier to reason about in termsof visibility. SupposeContextVar() is a namespace,and the consider the following:
    ns=contextvars.ContextVar('ns')defgen():ns.a=2yieldassertns.b=='bar'# ??defmain():ns.a=1ns.b='foo'g=gen()next(g)# should not see the ns.a modification in gen()assertns.a==1# but should gen() see the ns.b modification made here?ns.b='bar'yield

    The above example demonstrates that reasoning about the visibilityof different attributes of the same context var is not trivial.

  • Single-valueContextVar allows straightforward implementationof the lookup cache;
  • Single-valueContextVar interface allows the C-API to besimple and essentially the same as the Python API.

See also the mailing list discussion:[26],[27].

Coroutines not leaking context changes by default

In V4 (Version History) of this PEP, coroutines were considered tobehave exactly like generators with respect to the execution context:changes in awaited coroutines were not visible in the outer coroutine.

This idea was rejected on the grounds that is breaks the semanticsimilarity of the task and thread models, and, more specifically,makes it impossible to reliably implement asynchronous contextmanagers that modify context vars, since__aenter__ is acoroutine.

Appendix: HAMT Performance Analysis

../_images/pep-0550-hamt_vs_dict-v2.png

Figure 1. Benchmark code can be found here:[9].

The above chart demonstrates that:

  • HAMT displays near O(1) performance for all benchmarkeddictionary sizes.
  • dict.copy() becomes very slow around 100 items.
../_images/pep-0550-lookup_hamt.png

Figure 2. Benchmark code can be found here:[10].

Figure 2 compares the lookup costs ofdict versus a HAMT-basedimmutable mapping. HAMT lookup time is 30-40% slower than Python dictlookups on average, which is a very good result, considering that thelatter is very well optimized.

There is research[8] showing that there are further possibleimprovements to the performance of HAMT.

The reference implementation of HAMT for CPython can be found here:[7].

Acknowledgments

Thanks to Victor Petrovykh for countless discussions around the topicand PEP proofreading and edits.

Thanks to Nathaniel Smith for proposing theContextVar design[17][18], for pushing the PEP towards a more complete design, andcoming up with the idea of having a stack of contexts in the threadstate.

Thanks to Alyssa (Nick) Coghlan for numerous suggestions and ideas on themailing list, and for coming up with a case that cause the completerewrite of the initial PEP version[19].

Version History

  1. Initial revision, posted on 11-Aug-2017[20].
  2. V2 posted on 15-Aug-2017[21].

    The fundamental limitation that caused a complete redesign of thefirst version was that it was not possible to implement an iteratorthat would interact with the EC in the same way as generators(see[19].)

    Version 2 was a complete rewrite, introducing new terminology(Local Context, Execution Context, Context Item) and new APIs.

  3. V3 posted on 18-Aug-2017[22].

    Updates:

    • Local Context was renamed to Logical Context. The term “local”was ambiguous and conflicted with local name scopes.
    • Context Item was renamed to Context Key, see the thread with AlyssaCoghlan, Stefan Krah, and Yury Selivanov[23] for details.
    • Context Item get cache design was adjusted, per Nathaniel Smith’sidea in[25].
    • Coroutines are created without a Logical Context; ceval loopno longer needs to special case theawait expression(proposed by Alyssa Coghlan in[24].)
  4. V4 posted on 25-Aug-2017[31].
    • The specification section has been completely rewritten.
    • Coroutines now have their own Logical Context. This meansthere is no difference between coroutines, generators, andasynchronous generators w.r.t. interaction with the ExecutionContext.
    • Context Key renamed to Context Var.
    • Removed the distinction between generators and coroutines withrespect to logical context isolation.
  5. V5 posted on 01-Sep-2017: the current version.

References

[1]
https://go.dev/blog/context
[2]
https://docs.microsoft.com/en-us/dotnet/api/system.threading.executioncontext
[3]
https://github.com/numpy/numpy/issues/9444
[5]
https://en.wikipedia.org/wiki/Hash_array_mapped_trie
[6]
https://blog.higher-order.net/2010/08/16/assoc-and-clojures-persistenthashmap-part-ii.html
[7]
https://github.com/1st1/cpython/tree/hamt
[8]
https://michael.steindorfer.name/publications/oopsla15.pdf
[9]
https://gist.github.com/1st1/9004813d5576c96529527d44c5457dcd
[10]
https://gist.github.com/1st1/dbe27f2e14c30cce6f0b5fddfc8c437e
[17]
https://mail.python.org/pipermail/python-ideas/2017-August/046752.html
[18]
https://mail.python.org/pipermail/python-ideas/2017-August/046772.html
[19] (1,2)
https://mail.python.org/pipermail/python-ideas/2017-August/046775.html
[20]
https://github.com/python/peps/blob/e8a06c9a790f39451d9e99e203b13b3ad73a1d01/pep-0550.rst
[21]
https://github.com/python/peps/blob/e3aa3b2b4e4e9967d28a10827eed1e9e5960c175/pep-0550.rst
[22]
https://github.com/python/peps/blob/287ed87bb475a7da657f950b353c71c1248f67e7/pep-0550.rst
[23]
https://mail.python.org/pipermail/python-ideas/2017-August/046801.html
[24]
https://mail.python.org/pipermail/python-ideas/2017-August/046790.html
[25]
https://mail.python.org/pipermail/python-ideas/2017-August/046786.html
[26]
https://mail.python.org/pipermail/python-ideas/2017-August/046888.html
[27]
https://mail.python.org/pipermail/python-ideas/2017-August/046889.html
[28]
https://docs.python.org/3/library/decimal.html#decimal.Context.abs
[29]
https://web.archive.org/web/20170706074739/https://curio.readthedocs.io/en/latest/reference.html#task-local-storage
[30]
https://docs.atlassian.com/aiolocals/latest/usage.html
[31]
https://github.com/python/peps/blob/1b8728ded7cde9df0f9a24268574907fafec6d5e/pep-0550.rst
[32]
https://mail.python.org/pipermail/python-dev/2017-August/149020.html
[33]
https://mail.python.org/pipermail/python-dev/2017-August/149043.html

Copyright

This document has been placed in the public domain.


Source:https://github.com/python/peps/blob/main/peps/pep-0550.rst

Last modified:2025-02-01 08:59:27 GMT


[8]ページ先頭

©2009-2025 Movatter.jp