IPython工作原理
IPython是什么?
Python最有用的功能之一就是它的交互式解释器。交互式编程允许我们非常快速地执行代码片段、测试验证想法,而无需像大多数其他编程语言那样要先创建项目、创建源文件,然后才能执行。然而,Python自带的解释器对使用交互式解释器有一定的限制。IPython的出现就是为了弥补这些不足。
IPython的目标是为交互式和探索性计算创建一个全面、完整、易用的环境。为此IPython提供了三大组件:
- 一个加强版交互式Python命令行工具;
- 一种解耦的双进程通信模型,允许多个客户端连接到计算内核。著名的Jupyter就是基于这个特性开发的;
- 一个交互式并行计算架构,现在已经融合到
ipyparallel
包中。
IPython是完全开源的,大家可以在这里获取到IPython的源代码。
IPython安装也非常简单,只需要执行下面的命令即可:
$ pip install ipython
当然我们也可以只安装IPython内核,供Jupyter使用:
$ python -m pip install ipykernel
IPython工作原理
IPython控制台
当我们在控制台输入ipython
命令后,我们就进入了IPython原生的控制台界面,你将看到类似下面的控制台交互界面:
此时我们就可以直接在控制台中输入要运行的Python代码。
IPython控制台的核心引擎其实非常简单,类似下面的代码功能:
while True:
code = input(">>> ")
exec(code)
当然,IPython的实际代码要不这复杂,以为它还会处理多行代码、代码补全、历史记录、魔法命令等功能。但核心思路是一样的,都是提示用户输入代码,然后再同一进程执行所输入的代码。这其实就是REPL要做的事。
REPL是Read-Eval-Print-Loop的首字母,它是交互式编程的基础模型。
IPython内核
除了IPython自带的控制台,IPython还有很多第三方交互界面和工具,其中最著名的当属Jupyter Notebook。这些第三方界面要想驱动IPython工作就需要使用IPython内核。IPython内核运行在一个单独的进程中,负责运行用户的代码。前端通过ZeroMQ将消息以JSON格式发送给IPython内核,并通过 ZeroMQ socket与IPython内核保持通信,通信协议可以参考Jupyter消息传递。
IPython内核的核心执行机制与IPython终端共享:
图1. 执行器共享
一个内核进程可以同时连接多个前端,这是多个前端访问到的是同样的内存数据。
将内核和前端解耦的优点非常明显:
- 可以基于同一内核轻松开发不同的前端;
- 可以为同一前端提供其他语言的内核,Jupyter支持多种语言就是这一设计的直接受益者。
目前有两种方式开发其他语言的内核:
- 包装IPython内核,重用IPython的通信机制,只实现核心执行部分;
- 原生内核,用目标语言重新实现执行部分和通信部分。
图2. 包装内核 vs 原生内核
很明显,包装内核实现起来会更简单,因此如果目标语言与Python交互良好(比如octave_kernel),或者目标语言不方便实现通信的(比如bash_kernel),用包装内核实现会简单快速很多。
实现一个简单的包装内核
用包装IPython内核的方式实现新内核非常简单,关键步骤就是声明一个类,继承自ipykernel.kernelbase.Kernel
,然后实现相应的属性和方法。
其中必须提供的属性如下:
属性名 | 类型 | 说明 |
---|---|---|
implementation |
str | 内核名称 |
implementation_version |
str | 内核版本 |
banner |
str | 控制台启动时输入提示符出现前展示给用户的信息 |
language |
str | 内核语言 |
language_version |
str | 内核语言版本 |
language_info |
dict | 内核语言信息,字典类型,需要包括mimetype 目标语言的mimetype 和 file_extension 目标语言源代码文件后缀名称。 |
必须实现的方法只有一个 do_execute(code, silent, store_history=True, user_expressions=None, allow_stdin=False)
,用于执行用户代码。参数含义如下:
参数名 | 类型 | 说明 |
---|---|---|
code |
string | 要执行的代码 |
silent |
bool | 是否显示输出 |
store_history |
bool | 是否将代码存入历史库中并增加执行计数。如果silent 为True,则store_history 默认为False。 |
user_expressions |
dict | 代码执行完后,对要求值的表达式命名 |
allow_stdin |
bool | 前端是否可以根据请求提供输入 |
do_execute
方法返回一个字典,其中需要包含的字段在 Execution results 中有详细的介绍。要显示输出,可以使用send_response()
发送消息。有关各消息类型的详细信息,请参阅IPython中的消息传递。
内核写好后可以用IPKernelApp
的launch_instance
方法启动内核,传入我们自定义的内核类即可:
if __name__ == '__main__':
from ipykernel.kernelapp import IPKernelApp
IPKernelApp.launch_instance(kernel_class=MyKernel)
下面是一个完整的包装内核的实现例子:
from ipykernel.kernelbase import Kernel
class EchoKernel(Kernel):
implementation = 'Echo'
implementation_version = '1.0'
language = 'no-op'
language_version = '0.1'
language_info = {
'mimetype': 'text/plain'}
banner = "Echo kernel - as useful as a parrot"
def do_execute(self, code, silent, store_history=True, user_expressions=None,
allow_stdin=False):
if not silent:
stream_content = {
'name': 'stdout', 'text': code}
self.send_response(self.iopub_socket, 'stream', stream_content)
return {
'status': 'ok',
# The base class increments the execution count
'execution_count': self.execution_count,
'payload': [],
'user_expressions': {
},
}
if __name__ == '__main__':
from ipykernel.kernelapp import IPKernelApp
IPKernelApp.launch_instance(kernel_class=EchoKernel)
上面的代码简单地将用户输入直接输出出来。
除此之外,还需要提供一个内核描述文件用于指定如何运行内核
{
"argv":["python","-m","echokernel", "-f", "{connection_file}"],
"display_name":"Echo"
}
代码在IPython内核中的执行流程
当IPython内核收到代码执行请求时,会按照一下步骤处理:
- 触发
pre_execute
事件; - 如果
silence
不为True,则触发pre_run_cell
事件; - 执行
run_cell
方法,run_cell
方法会对传入代码进行预处理,然后编译执行它。这是整个执行流程的主体,后面会详细讲解这个过程; - 如果执行成功,定义在
user_expressions
中的表达式会触发求值,这样做的好处是可以确保表达式中的任何错误都不会影响主代码的执行; - 触发
post_execute
事件; - 如果
silence
不为True,则触发post_run_cell
事件。
其中代码的执行部分又会分2个阶段:首先IPython.core.inputtransformer2
会将代码块中的%magic
和!system
命令展开转换成python代码;然后用Python的 compile()
方法编译并执行代码。
图3. IPython代码执行流程
Python的compile()
方法提供了mode
参数可以选择编译方式,有三种方式可选:
single
对于单一交互语句有效(尽管代码可以包含多行,例如for循环)。在这种模式下编译时,生成的字节码包含特殊指令,这些指令会触发对代码块中返回的任何表达式调用sys.displayhook()
。这意味着一条语句实际上可以产生多次sys.displayhook()
调用,例如下面的代码:
for i in range(10):
i**2
表达式包含在循环中,且表达式的值没有赋给任何变量,此时将调用10次sys.displayhook()
。
exec
编译任意数量的代码,pyhton模块就是用这种方式编译的。这种编译模式不会调用sys.displayhook()
。
eval
执行单一表达式,不会调用sys.displayhook()
。
传入的源代码会被划分为若干独立的代码块,每个块确保可以使用single
模式编译。如果只有一个代码块,那就用single
模式编译;如果有多个代码块,则按照如下逻辑执行:
- 如果最后一个代码块不超过两行,则最后一个代码块之前的代码用
exec
模式编译,只有最后一个代码块用single
模式编译。这样可以很容易地在末尾输入简单表达式查看计算结果。 - 如果最优一个代码块大于两行,则全部用
exec
模式编译。