Blog YON

Pyhon decorator descriptor and metaclass

backtrader 支持在线实时的数据源输入, 目前支持海外的 IbPy (盈透), Oanda 等方式的接入, 不支持我准备用的富途, 于是准备看源码参照 IbPy, Oanda 接入的方式, 自己对接下富途. 看了下源码, 发现用到了 Python 的几个特性: 装饰器, 描述符, 元类. 以前简单接触过, 理解不是太深, 所以这次就稍微整理下.

装饰器 decorator

Decorator 还是比较好理解的, 它本质上来说就是个函数 (严格来说只要是 callable 的就行), 如果你把它放在方法上, 那么它的入参就是这个方法, 返回的新方法就会替代原本的方法定义. 如果放在类上, 那么它的入参就是就是这个类, 返回的新类就会替代原本的类定义.

Decorator 有两种形式, 一种就是直接的 @decorator 形式, 这个就是我上面说的. 另一种是 @decorator(arg1, arg2, ...) 这种用法其实是前一种的变体, decorator(arg1, arg2, ...) 就是个简单的方法调用, Python 会把它的返回值当成真正的装饰器来解析.

描述符 descriptor

Descriptor 的定义很简单, 只要是一个类定义了下面几个方法, 那么它就是一个描述符:

descr.__get__(self, obj, type=None) -> value

descr.__set__(self, obj, value) -> None

descr.__delete__(self, obj) -> None

如果一个描述符只定义了上述方法中的 __get__ 方法, 那么它就是一个 non-data 的描述符, 如果除了 __get__ 以外还定义了其它的方法 ( __set__ 或者 __delete__ ), 那么就是一个 data 的描述符. data 与 non-data 的描述符有什么区别? 要回答这个问题, 我们要先明白, 描述符的作用是什么. 首先描述符只有被定义为类变量 (class variable [1]) 时才有意义, 当你使用描述符时, Python 会调用相应的描述符的方法而不是直接操作对应的描述符. 可以理解为描述符提供了一种对类变量的 get ( Clz.descr ), set ( Clz.descr = x ), delete ( del Clz.descr ) 进行重载的机制. 不过这边有点不符合直觉的是, 一般我们重载方法的时候是重载的主语, 所以按道理来说我们要重载 Clz.descr 这个表达式的话, 应该是在 Clz 中定义 __get__ 来重载, 但是描述符的重载是在宾语上发生的, 我们是在 descr 上定义的 __get__ 方法. 不过这个也好理解, 在 descr 定义就不用在每个用到描述符的地方都重复定义一遍了.

下面来回答 data 与 non-data 的描述符的区别. 当我们使用 obj.descr  表达式时, 如果对象中找不到 descr  Python 就会找到 Clz.descr  然后走描述符那套处理逻辑, 那么对象中有同名的 descr  时会怎么样呢? 这时候就要看 descr  是 data 还是 non-data 了. 如果是 data 的, 那么 Clz.descr  的优先级就高于 obj.descr . 如果是 non-data 的, 那么 obj.descr  的优先级就高于 Clz.descr  不会走描述符那套逻辑. 具体解析顺序的实现在 object.__getattribute__()  中, 下面是用 Python 描述的实现逻辑:

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = getattr(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

常见的 @staticmethod 与 @classmethod 就是将装饰器与描述符结合起来的一种应用. 下面是对应的 Python 的实现版本:

class StaticMethod:
    "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f

    def __get__(self, obj, objtype=None):
        return self.f
    
class MethodType:
    "Emulate Py_MethodType in Objects/classobject.c"

    def __init__(self, func, obj):
        self.__func__ = func
        self.__self__ = obj

    def __call__(self, *args, **kwargs):
        func = self.__func__
        obj = self.__self__
        return func(obj, *args, **kwargs)
    
class ClassMethod:
    "Emulate PyClassMethod_Type() in Objects/funcobject.c"

    def __init__(self, f):
        self.f = f

    def __get__(self, obj, cls=None):
        if cls is None:
            cls = type(obj)
        if hasattr(type(self.f), '__get__'):
            # 这段逻辑是 Python 3.9 新增的, 用于实现描述符的链式调用 [1]
            # 原文档里上面的 if 判断条件是 hasattr(obj, '__get__'), 这个是错误的
            return self.f.__get__(cls)
        return MethodType(self.f, cls)

元类 metaclass

元类算是这几个概念里比较难理解的了, 不过只要我们记住在 Python 里任何东西都是对象这个知识点, 那么理解元类就比较简单了. 任何东西都是对象, 那么类自然也不例外. 类的类被称为元类, 一般情况下, 我们通过 class 定义的类的元类是 type , type 的元类是 type 自身. 我们通过 type(Clz) 表达式来查看一个类的元类是什么.

在 Python2 中要指定一个类的元类通过 __metaclass__ 这个类变量来指定, 要注意的是, 只有当我们使用 class 关键字来定义一个类的时候, __metaclass__ 才生效 [2]. Python3 中使用下面的语句来指定一个类的元类:

class Clz(metaclass=type):
    pass

哪些对象能作为元类的? 答案是任何 callable 的对象. 当你指定了元类, Python 在创建类的时候会调用你指定的元类, 并传入以下参数:

  1. name: 当前类的名字
  2. bases: 当前类的基类
  3. attrs: 当前类的属性


一个类的默认的元类是 type , 所以当你使用 class 关键字来定义类时, 等价于下面两条语句:

class Clz(object):
    hello = 'world'

# 等价
Clz = type('Clz', (object,), {'hello': 'world'})

# 等价
Clz = type.__new__(type, 'Clz', (object,), {'hello': 'world'})

上面两种写法有什么区别呢? 只要了解了 __init__ 和 __new__ 的区别就明白了. __init__ 我们都知道, 当我们执行 Clz(arg1, arg2) 创建对象时, Python 会执行 Clz.__init__(self, arg1, args) 其中 self 表示当前正在创建的对象. 很自然的一个问题就是, self 哪里来的呢? self 就是通过 __new__ 创建出来的. 总结下就是, __new__ 创建对象, __init__ 初始化对象, 所以当我们执行 Clz(arg1, arg2) 时等价于执行下面的语句:

# Clz(arg1, arg2) 等价于
obj = Clz.__new__(Clz, arg1, arg2)
obj.__init__(arg1, arg2)

顺便再多说几句, 官方文档里提到 [3]:

If new() does not return an instance of cls, then the new instance’s init() method will not be invoked.


也就是说, 如果我们重写 __new__  方法, 但是 __new__  返回的对象的类不是第一个参数指定的类,
那么新创建的对象的 __init__  方法不会被调用:

class Foo:
    def __new__(cls, *args, **kwargs):
        print('Foo new')
        return Bar.__new__(Bar, *args, **kwargs)


class Bar:
    def __init__(self):
        print('Bar init')

# 不会调用到 Bar 的 __init__ 方法
Foo()


说了这么多的废话, 好像和元类一点关系都没有. 其实不是的, 我们看下 type 的 __new__ 方法的定义, 第一个参数表示要创建的对象的类, 而我们现在创建的对象就是一个类, 类的类可不就是我们所说的元类吗? 需要说明的是, 如果使用 type 的 __new__ 方法来创建类并指定元类, 那么就仅仅是指定了新创建类的元类, 新类并不是通过 call 元类来创建的.

最后我们说下元类的继承问题, 一句话, 一个类的元类如果没有指定的话会从父类继承. 虽然说任何 callable 的对象都可以当做元类, 但是如果我们想要写个可以被继承的元类就需要用 type.__new__ 来创建类:

class MetaClz(type):
    def __new__(mcs, *args, **kwargs):
        return type.__new__(mcs, *args, **kwargs)

class Foo(metaclass=MetaClz):
    pass

class Bar(Foo):
    pass

MetaClz 的 __new__ 方法中创建了一个类, 并且指定了这个类的元类, 所以最终创建出来的 Foo 的元类是 MetaClz , Bar 会继承 Foo 的元类 MetaClz , 所以 Bar 的元类也是 MetaClz . 我们再看另外一种错误的写法:

class MetaClz(type):
    def __new__(mcs, *args, **kwargs):
        return type(*args, **kwargs)

class Foo(metaclass=MetaClz):
    pass

class Bar(Foo):
    pass

和之前的写法唯一的区别就是我们直接用了 type() 来创建类, 这样写的话, 最终创建出来的 Foo 的元类是 type , Bar 会继承 Foo 的元类 type , 所以 Bar 的元类也是 type . 那么谁的元类是 MetaClz 呢? 答案是下面这个语句的元类是 MetaClz :

# 这条语句的元类是 MetaClz
class Foo(metaclass=MetaClz):
    pass


[1] Descriptor HowTo Guide
[2] Inheritance of metaclass
[3] Data model

GS108PEv3 实现单线复用

更新

打电话给电信把光猫改成桥接模式,在路由器输入宽带账号和密码始终连接不上,摸索了一阵发现,原来使用 PPPoE 模式时,路由器 CPU 的口子不再是 8 口,而是 5 口。同时,从光猫来的数据送到 5 口时需要是 Untagged 的。另外就是在给 CPU 的口子配 Untagged 时必须强制指定 U,否则就是 T 的。


原文

家里网络很早之前就改造好了, 今天忽然想要改下交换机的配置, 但是因为时间太长, 一下次想不起来怎么连上交换机了. 折腾了好一会才连上, 所以决定把家里网络的相关改造记录下, 做个备份.

为什么需要单线复用?

现在我们上网通常只需要一个电信猫, 这个猫一般是具备直接拨号的和 WIFI 连入的, 只是性能和可玩性比较差, 所以我们还是会再接一个路由器, 我用的是淘宝淘的二手 NETGEAR R6300v2. 这样一来, 我们就需要把电信猫和路由器同时放到网络设备箱里, 如果你像我一样用的是 R6300v2 这种比较大的路由器可能就放不下了, 另外无线路由器放在网络设备箱里也会影响无线信号. 最好的办法就是, 无线路由器可以放到任意有有线的位置, 比如客厅. 但是像客厅这些地方, 一般只有一根网线用于连接路由器的 WAN 口, 这样其他地方的网线就无法直接与路由器相连了. 怎么办呢? 这时候就需要我们进行单线复用了, 也就是通过 WAN 口既可以与电信猫传输数据, 又可以与本地 LAN 的设备通信.

前置知识点

实现单线复用用到的关键手段是 VLAN.

VLAN

当我们本地网络有 N 个设备, 这些设备需要相互通信的话, 就需要借助于交换机. 有时候出于安全考虑, 我们希望本地网络中可以手动划分设备分组, 只有被划到一个分组中的设备可以相互通信, 不同组之间的设备相互隔离. 要实现这个功能, 就需要用到 VLAN. 现在通常用的 VLAN 协议是 802.1Q, 协议会在数据链路层的数据包中插入 VID (VLAN ID), 交换机根据 VID 进行相应的数据处理转发. 在 802.1Q 协议之前, 还有 Port-based VLAN, 使用这种 VLAN 协议, 数据包只在交换机内部有 VLAN 的概念, 出了交换机, 数据包就和普通的数据包没有区别了, 所以 Port-based VLAN 无法进行交换机的级联. 我们主要介绍 802.1Q 的 VLAN 协议. 对于交换机上的端口来说有三种类型:

  • Access
  • Trunk
  • Hybird


这几种类型有什么区别呢? 我们从端口收到数据的处理方式来看. 对于一个端口来说, 它可以从交换机外部接收到数据, 也可以从交换机内部接收到数据. 对于前面一种我们暂时把它叫做 input, 后一种叫做 receive. 对于 input 来说, 有可能接收到不带 VID 数据, 也可能接收到带 VID 的数据. 对于 receive 来说接收到的都是带 VID 数据. 顺便再说下, 每个端口还有一个 PVID (Port VLAN ID) 的属性, 一个端口可以属于多个 VLAN, 但是只能有一个 PVID. 下面我们就从前面说的几个方面来看几种端口类型的区别:

  • Access
    • input
      • 有 VID: 直接丢弃
      • 无 VID: VID 打上 PVID 的标, 再发送给属于同一个 VLAN 的端口
    • receive
      • VID 与 PVID 相同: 将 VID 剥离后转发出去
      • VID 与 PVID 不同: 直接丢弃
  • Trunk
    • input
      • 有 VID: 端口属于 VID 的 VLAN 就转发, 不属于就丢弃
      • 无 VID: 同 Access 处理方式
    • receive
      • VID 与 PVID 相同: 同 Access 处理方式
      • VID 与 PVID 不同: 端口属于 VID 的 VLAN 就直接转发 (不剥离 VID 信息), 否则丢弃
  • Hybird
    • input
      • 有 VID: 同 Trunk 处理方式
      • 无 VID: 同 Access 处理方式
    • receive
      • VID 与 PVID 相同: 同 Access 处理方式
      • VID 与 PVID 不同: 如果端口不属于 VID 的 VLAN 就直接丢弃, 如果属于 VID 所在 VLAN 还要看端口在该 VID 下是配置的 U (Untag) 还是 T (Tag), 如果是 U 就将 VID 剥离后转发, 否则就直接转发

WAN

之所以单线复用可以成功的另一个因素是, 路由器区分 WAN 口与 LAN 的底层是基于 VLAN 实现的. WAN 口与 LAN 口属于不同的 VLAN , CPU 根据 VID 来决定是按照 WAN 还是 LAN 来处理. 所以只要我们交换机将从电信猫过来的数据打上和 WAN 相同的 VID, 从本地网络的过来数据打上与 LAN 相同的 VID, 路由器就可以正常工作了.

实操

知道了原理, 剩下的就好办了.

交换机配置

先连上交换机, GS108PEv3 是可网管的交换机 (v2 是不行的, 只能用专用的工具连接). 如果不知道交换机的 IP 可以用 Netgear 提供的工具来设备发现 (需要与交换机直连, 中间不能有路由器) [1, 2, 3].

网件的端口都是 Hybird 类型的. 我这边是将 5 号口与路由器 WAN 口相连, 8 号口与电信光猫相连, 其余的口用于 LAN 设备连接.

VLAN ID Port Members
1 1U 2U 3U 4U 5T 6U 7U
2 5T 8U

VLAN 1 中的 5 号口设置成 T, 其余口设置成 U. VLAN 2 中 5 号口也设置成 T, 8 号口设置成 U. 最后把 8 号口的 PVID 设置成 2, 其余口设置成 1.

顺便说下, 网件交换机里, T 表示 Tagged, U 表示 Untagged, 没有标记表示这个端口不属于这个 VLAN [4].

路由器配置


我们先 SSH 到路由器, 对于 R6300v2 使用 robocfg show 查看 VLAN 设置:

# robocfg show
Switch: enabled 
Port 0:   DOWN enabled stp: none vlan: 1 jumbo: off mac: 00:00:00:00:00:00
Port 1:   DOWN enabled stp: none vlan: 1 jumbo: off mac: 00:00:00:00:00:00
Port 2: 1000FD enabled stp: none vlan: 1 jumbo: off mac: 00:11:32:bb:c3:45
Port 3:   DOWN enabled stp: none vlan: 1 jumbo: off mac: 00:00:00:00:00:00
Port 4: 1000FD enabled stp: none vlan: 2 jumbo: off mac: 38:f9:d3:19:0a:6b
Port 5: 1000FD enabled stp: none vlan: 1 jumbo: off mac: dc:ef:09:94:2d:f7
Port 7:   DOWN enabled stp: none vlan: 1 jumbo: off mac: 00:00:00:00:00:00
Port 8: 1000FD enabled stp: none vlan: 1 jumbo: off mac: dc:ef:09:94:2d:f7
VLANs: BCM5301x enabled mac_check mac_hash
   1: vlan1: 0 1 2 3 4t 8t
   2: vlan2: 4t 8t
  56: vlan56: 8u
  57: vlan57: 2t 4t 5t 8t
  58: vlan58: 4 7 8t
  59: vlan59: 0 1t 2 3t 5
  60: vlan60: 0 4t
  61: vlan61: 0 2t 3 5 8u
  62: vlan62: 0t 1t 2t 3t

上面是我现在的配置, 很多是路由器默认的, 和我们这次相关的就是 vlan1 与 vlan2 的配置. (这里带 t 的表示 Tagged, 没有 t 的表示 Untagged, 与交换机有区别). 对于我的路由器来说, 4 口是 WAN 口, 8 口是什么呢? 8 口代表的是 CPU, 路由器把 CPU 也抽象成了一个端口.

Port 4: 1000FD enabled stp: none vlan: 2 jumbo: off mac: 38:f9:d3:19:0a:6b 中的 vlan: 2 代表的是端口的 PVID, 我们可以用 robocfg port 4 tag 2 来配置 [5].

为了路由器每次重启都能自动配置 VLAN, 我们需要在 /jffs/scripts/services-start 中配置启动命令 [6]:

#!/bin/sh
robocfg vlan 2 ports "4t 8t"
robocfg vlan 1 ports "0 1 2 3 4t 8t"

这样, 单线复用就配置好了.

备注

家里各设备的网管地址 (年纪大了, 记性不好):

  • 路由器: 192.168.50.1
  • 交换机: 192.168.2.4 (密码要求有大小写与数字)
  • AP: 到路由器里找 AP-105 的 IP
  • 电信猫: 192.168.2.1, hcsk5


[1] GS108PEv3 — 8 Port Gigabit Ethernet PoE Smart Managed Plus Switch with 4-Ports PoE
[2] What is the default IP address of my NETGEAR Smart Managed Plus, Smart Managed Pro, or Insight Managed Smart Cloud switch?
[3] How do I access the admin page of my ProSAFE Web Managed Plus or Click Switch?
[4] 简单网管交换机的 VLAN 功能设置及应用
[5] 单线复用实践篇 #74
[6] 网件GS108PE交换机,搭配R8500梅林固件,实现单线复用

Mac 调试编译 OpenJDK9

几年前花费了很大精力在 Mac 下把 JDK7 编译了出来, 中间踩了无数的坑, 前几天想编译调试 JDK9 看看各种锁优化的实现. 本以为参考下之前的文章很快就能编译出来, 没想到很多工具都已经失效, Mac 系统的更新更是让编译雪上加霜.

放心, 这篇文章不会又是一篇填坑指南. 我自己填坑的时候忽然想到, 为什么不在 Docker 里编译呢?

使用 Docker 的好处就是环境统一, 另外采用 Linux 系统, 构建起来也更方便, 缺什么就安装什么. 下面就是我用的可以成功编译 OpenJDK9 的 Dockerfile:

FROM ubuntu:18.04

RUN apt update && \
    apt install -y vim gdb file build-essential unzip zip \
    openjdk-8-jdk libx11-dev libxext-dev libxrender-dev libxtst-dev libxt-dev \
    libcups2-dev libfreetype6-dev libasound2-dev libelf-dev

CMD bash


编译环境有了, 还缺的就是源码了, 有很多下载方式, 我是从这里下载的 http://jdk.java.net/java-se-ri/9.

假设你下载解压后的代码放在 ~/Downloads/openjdk . 现在我们启动 Docker:

docker run -v ~/Downloads/openjdk:/openjdk -it -p 1234:1234 --rm --security-opt seccomp=unconfined jdkbuilder

其中 --security-opt seccomp=unconfined 是为了让后面用 gdb 顺利调试用的. 映射 1234 端口是为了后面远程 Debug.

启动镜像后, 切换到 /openjdk 目录, 执行下面命令:

 bash configure --with-jvm-variants=server \
     --disable-warnings-as-errors \
     --with-debug-level=slowdebug \
     --with-native-debug-symbols=internal

--with-debug-level=slowdebug 和 --with-native-debug-symbols=internal 是为了之后调试用的, --disable-warnings-as-errors 是让编译器忽略 warning, --with-jvm-variants=server 是编译服务器版本的 jvm.

configure 如果顺利执行, 就可以用 make images 来开始编译了, 我 docker 用 6 核 8 G 内存大概要 1 个多小时.

如果一切顺利, 编译好的 jdk 就在 /openjdk/build/.../jdk 下面, 可以用 gdb --args ./java -version 来试着调试. 调试的时候会提示有 received signal SIGSEGV, Segmentation fault. 的错误, 不用管, 直接 continue 就行.

如果觉得用 gdb 不方便, 也可以在 Docker 里启动 gdbserver 后用 CLion 远程 Debug:

# 容器里启动 gdbserver
gdbserver :1234 ./java -version

CLion 里创建一个 GDB Remote Debug 的配置, target 里填入: 127.0.0.1:1234 . Path mappings 里, Remote 填入 /openjdk, Local 里填入你对应的本地地址 /Users/.../Downloads/openjdk .

CLion 支持一整套的远程开发流程了, 包括代码同步, 编译, 部署, 调试. 不过只支持 CMake 的项目, 我就懒得弄了, 反正就是准备简单看看代码调试的, 而且 CLion 我也只是试用版, 就用 GDB 从命令行调试得了.

Java 锁优化

JDK6 之前大家对 synchronized 的印象就是慢,所以 JDK6 对 synchronized 做了大量的优化。主要有:

  • 自旋锁 Spinlock
  • 适应性自旋 Adaptive Spinning
  • 锁消除 Lock Elimination
  • 锁粗化 Lock Coarsening
  • 偏向锁 Biased Locking
  • 轻量级锁 Lightweight Locking


自旋锁以及它的进阶版适应性自旋优化针对的场景是,大部分时候线程持锁的时间是比较短暂的,我们没必要让线程使用重量级的系统调用来等待唤醒,可以增加一些空循环,说不定就能获取到锁了。普通自旋锁这个空循环的次数是固定的,适应性自旋会根据运行时的信息,来动态调整空循环的次数。

锁消除与锁粗化比较简单,就是有时候一个对象根本不会被并发使用,所以完全没必要去做加锁的操作,或者对于一个锁连续的加锁解锁可以合并成一次加锁解锁。

偏向锁针对的场景是,大部分第三方库是针对并发场景开发的,但是很可能在实际使用的时候并不会有并发发生,所以对于这种场景,一旦一个线程获取了这个锁,下次使用的时候就不用去执行昂贵的加锁操作了。

轻量级锁与偏向锁类似,它针对的场景是,虽然程序会有并发,但是大部分时候并不会触发竞争,所以可以用一些轻量级的方式来代替重量级的系统实现。

对于偏向锁与轻量级锁他们只是假设了某种场景,但是实际中可能这个假设是错误的,也就是说发生了竞争,那么这些锁就需要升级,偏向锁升级为轻量级锁,轻量级锁升级为重量级锁,升级的具体过程就要去看源码了,就不展开了。

锁这个词即是名词又是动词,我的理解是当强调锁的作用时应该用名词的锁 (Lock),强调锁的方式时用动词的锁 (Locking)。不过很多时候在英文里,Lock 与 Locking 各种混用。

https://github.com/farmerjohngit/myblog/issues/12 https://m.douban.com/book/subject/34907497/


Get your hands dirty

用惯了 SpringBoot 都不知道一个 Web 服务到底怎么起来的了,就像用惯了 IDE 很多人连手写一个 Java 的 HelloWorld 并编译运行都做不到了。

SpringBoot 的启动依赖于 Servlet 3.0 的规范。3.0 以前 Servlet 启动是从读取 web.xml 文件开始的。3.0 开始支持不用配置 web.xml 启动,你只需要实现 ServletContainerInitializer 接口,然后基于 SPI 机制在
META-INF/services/javax.servlet.ServletContainerInitializer 文件里配上你的类名,这样 Tomcat 启动时就会基于 Servlet 规范执行你的类。SpringBoot 就是基于的这个机制。

另外补充下 SOFA 的启动, SOFA 会自动将 META-INF/spring 下的 Spring 配置文件加载.

明天早上起来如果下雨就不去晨跑去冥想了。

https://www.sofastack.tech/projects/sofa-boot/sofaboot-module/