The semantics are pretty much the same. Both are stored in the ExecutionContext and flow through async calls.
The differences are API changes (just as you described) together with the ability to register a callback for value changes.
Technically, there’s a big difference in the implementation as the CallContext is cloned each time it is copied (using CallContext.Clone) while the AsyncLocal‘s data is kept in the ExecutionContext._localValues dictionary and just that reference is copied over without any extra work.
To make sure updates only affect the current flow when you change the AsyncLocal‘s value a new dictionary is created and all the existing values are shallow-copied to the new one.
That difference can be both good and bad for performance, depending on where the AsyncLocal is used.
Now, as Hans Passant mentioned in the comments CallContext was originally made for remoting, and isn’t available where remoting isn’t supported (e.g. .Net Core) which is probably why AsyncLocal was added to the framework:
#if FEATURE_REMOTING
public LogicalCallContext.Reader LogicalCallContext
{
[SecurityCritical]
get { return new LogicalCallContext.Reader(IsNull ? null : m_ec.LogicalCallContext); }
}
public IllogicalCallContext.Reader IllogicalCallContext
{
[SecurityCritical]
get { return new IllogicalCallContext.Reader(IsNull ? null : m_ec.IllogicalCallContext); }
}
#endif
Note: there’s also an AsyncLocal in the Visual Studio SDK that is basically a wrapper over CallContext which shows how similar the concepts are: Microsoft.VisualStudio.Threading.