您的位置:首页 > 其它

深入理解Lustre文件系统-第4篇 LDLM:Lustre分布式锁管理

2012-08-30 23:39 330 查看
分布式锁是保护分布式系统中共享资源的一种常用机制。LDLM(Lustre Distributed Lock Manager)是Lustre的分布式锁管理者,其基本思想来自于VAX Clusters DLM。

4.1 命名空间

每个分布式锁都保护了某个服务器上提供的分布式共享资源,LDLM用命名空间(namespace)来区分这些资源。每个分布式锁都属于某个LDLM命名空间。服务器上按照提供服务类型的不同,将命名空间分为LDLM_NS_TYPE_MGT、LDLM_NS_TYPE_MDT、LDLM_NS_TYPE_OST几类,相应地,客户端上的命名空间的类型有LDLM_NS_TYPE_MGC、LDLM_NS_TYPE_MDC、LDLM_NS_TYPE_OSC。客户端上的命名空间并不是一个独立、完整的命名空间,它只是映射了一部分服务端上的命名空间,是仅由该客户端使用的名字空间,因此这种命名空间被称为影子名字空间(Shadow
Namespace)。下图给出了影子命名空间与服务端命名空间之间的关系。



图 影子命名空间与服务端命名空间之间的关系
命名空间由类型为ldlm_namespace的对象来描述。在服务启动函数mgs_setup()、mdt_init0()、filter_common_setup()里,会调用ldlm_namespace_new()函数来新建一个类型分别为LDLM_NS_TYPE_MGT 、LDLM_NS_TYPE_MDT、LDLM_NS_TYPE_OST的命名空间。而在客户端的OBD设备配置函数client_obd_setup()中,也会调用ldlm_namespace_new()函数来新建一个对应类型的影子命名空间。OBD设备是在Lustre部件上提供的一个抽象层,它对各类差异化的部件提供了一般化的操作方法。对于OBD设备,我们将在将来进行专门的分析。

4.2 锁的类型

LDLM中定义了四类锁,客户端根据要保护的资源类型的不同,决定请求哪一类锁。这四类锁的类型分别是:

LDLM_PLAIN,平凡锁。在MGC请求从MGS处获得配置日志前,将在函数中mgc_process_log()函数,对之上一个LCK_CR模式的LDLM_PLAIN锁。而MGS在要修改配置时,会调用mgs_revoke_lock()函数,请求上LCK_EX模式的LDLM_PLAIN锁。对MGS来说,这个锁实际上保护的就是本地资源,因此这里把上锁过程称为回收锁(revoke lock)。此时所有的客户端,都会由于锁被回收,而重新获取配置日志,获得跟新。这个过程是以回调函数mgc_blocking_ast()的形式被引发的,而真正调用mgc_process_log()函数的却是运行mgc_requeue_thread()函数的ll_cfg_requeue线程。关于MGS的配置日志,我们将在以后的内容中进行分析。

LDLM_EXTENT,范围锁,用来保护位于OST上的对象数据,由类型为ldlm_extent的对象描述。顾名思义,范围锁可用来保护文件某个范围内的数据。范围锁被用在三种情形下,一是在读写时,二是在计算文件大小时,三是在文件截断时。在第一种情况下,在对文件的某个范围内的数据进行实际读写前,ll_file_io_generic()会对这个范围内上一个LDLM_EXTENT类型的LCK_PR模式或LCK_PW模式的锁。如果这种情况是追加写这种特殊情况,那么上锁的范围则是[0,EOF]。在第二种情况下,cl_glimpse_size0()在调用cl_io_init()函数初始化时确定了对文件上锁的范围是[0,
EOF],而它接着调用的cl_glimpse_lock()函数会获取模式为CLM_PHANTOM(phantom意为幻影)的锁。客户端范围锁有四种模式CLM_PHANTOM、CLM_READ、CLM_WRITE和CLM_GROUP,后三种将在osc_lock_build_einfo()函数中对应到分布式锁中的LCK_PR、LCK_PW和LCK_GROUP模式。CLM_PHANTOM是专用作获取文件大小的范围锁模式,它在osc_lock_build_einfo()函数中会被等同看作CLM_READ模式。在第三种情况下,会对文件加上[size,
EOF],其中size是截断要保留的文件大小。上面三种情况,不管是哪种,锁的范围都是在cl_io_init()函数最终调用lov_io_slice_init()函数时确定的,而锁类型、模式回调函数等信息的初始化发生在cl_io_loop()函数最终调用osc_lock_init()函数时。cl_io_init()函数和cl_io_loop()函数都是被称为CLIO(CLient IO)的客户端I/O流程的一部分。这个流程异常复杂,我们将在以后的内容中进行详细分析。

LDLM_FLOCK,Flock锁,用来协助用户空间请求文件锁,由类型为ldlm_flock的对象描述。我们应该注意到,在默认情况下,Lustre是不支持文件锁的,除非在安装Lustre客户端时显式加上-o flock参数或-o localflock参数。其中,如果使用-o flock参数安装Lustre客户端,那么该客户端和所有使用相同参数的客户端保持一致的文件锁视图,Lustre会通过LDLM机制维持这些客户端的文件锁的一致性。如果使用-o localflock参数,那么文件锁将只会在该客户端内保持一致,而不会与其他客户端产生相互影响,LDLM机制将不会介入,而只由Linux内核管理文件锁。在这里,如无特殊说明,我们仅分析使用完整的文件锁支持的情形。此时,VFS层的文件操作方法中flock和lock方法都被设定为ll_file_flock()函数。通过系统调用flock()或fcntl(),用户进程可以对文件进行加上FL_FLOCK
或FL_POSIX 类型的锁。对于前者,根据其语义,ll_file_flock()函数对整个文件从头到尾加上LDLM_FLOCK类型的锁;而对于后者,ll_file_flock()函数对文件的某个范围加上LDLM_FLOCK类型的锁。我们可以发现, LDLM_FLOCK也是对文件的某个范围上锁,这点与LDLM_EXTENT类似,但与之不同的是,LDLM_EXTENT的锁请求会被发送到MDS而不是OST。

LDLM_IBITS,索引节点比特锁(Inode Bit Lock),用来保护元数据,由类型为ldlm_inodebits的对象描述。元数据就是对关于数据的数据的总称,它可以分为很多种类。LDLM将元数据分为四类,其中目录项、文件模式、所有者、组被归为一类,对应索引节点比特锁的一个比特MDS_INODELOCK_LOOKUP;文件长度,引用数,时间戳被归为一类,对应MDS_INODELOCK_UPDATE比特;打开文件对应MDS_INODELOCK_OPEN比特;分条布局信息对应MDS_INODELOCK_LAYOUT比特。这四个比特共同构成了索引比特锁的各个部分,也是它被称为索引比特锁的原因。相比于传统的平凡锁方式,这种细分方式有助于提高目录访问的并发度。客户端在要读取某文件的元数据信息时,会对该文件加上索引节点比特锁。例如,在ll_get_dir_page()函数读目录内容时会对该目录加上LCK_PR
模式的索引节点比特锁。

4.3 锁的模式

在上面的内容中,不仅介绍了锁的类型,还涉及到了锁的另外一个维度,即锁的模式。按照锁互相之间的相容关系,它们可分为以下几种模式:

LCK_EX,独占模式(Exclusive mode)。客户端在调用mdc_intent_open_pack()函数时,在新文件创建前,将请求对父目录上LCK_EX模式的索引节点比特锁。

LCK_PW,保护写模式(Protective Write mode)。当客户端请求写文件时,此外,当客户端调用flock()系统调用对文件上写锁时,将在ll_file_flock()函数中,请求对文件上LCK_PW模式的Flock锁。

LCK_PR,保护读模式(Protective Read mode)。与LCK_PW模式相对的,当客户端请求读文件时,将请求一个LCK_PR模式的范围锁。此外,当客户端调用flock()系统调用对文件上读锁时,将在请求ll_file_flock()函数中对文件上LCK_PR模式的flock锁。在运行可执行文件时,客户端调用mdc_intent_open_pack()请求对该文件上LCK_PR模式的inodebit锁,使得该文件在执行时无法被另一进程以写模式打开。在读目录时,ll_get_dir_page()函数会对该目录加上LCK_PR模式的索引节点比特锁,使得该目录被修改前会通知该客户端,以维持最新视图。

LCK_CW,并行写模式(Concurrent Write mode)。如果客户端以写模式或以O_TRUNC方式打开文件,将在调用mdc_intent_open_pack()函数时,请求对该文件加上LCK_CW模式的索引节点比特锁。

LCK_CR,并行读模式(Concurrent Read mode)。如果客户端不是以写权限,也不是以O_TRUNC方式打开文件,又不是以执行方式打开文件,那么在调用mdc_intent_open_pack()函数时,将请求对该文件加上LCK_CR模式的索引节点比特锁。同时,如果该打开操作,不是创建一个新文件,那么将在调用mdc_intent_open_pack()函数时,请求对父目录加上LCK_CR模式的索引节点比特锁。

LCK_NL,空模式(Null mode)。在解锁文件锁时,ll_file_flock()会将锁的模式设置成LCK_NL,锁的类型仍为LDLM_FLOCK,这样,MDS就会解锁对应的文件锁。

LCK_GROUP,组锁模式(Group Lock mode)。组锁是在LDLM上引入的一种文件上锁机制,支持多个进程对同一文件的所有数据块上锁。组锁使用组ID作为标识,一组进程可以通过ioctl接口对某个文件加上具有同一组ID的组锁。如果文件已经被加上某种组锁,那么任何对该文件的读写访问或者对该文件加上另外一个具有不同组ID的组锁的行为,都将被阻塞或者出错返回。客户端进程如果需要访问文件的数据,必须首先对文件加上具有同样组ID的组锁,成为组员,从而被允许进行文件数据访问。对于已加上组锁的文件,分布式文件系统将不再对它的数据提供分布式锁管理,而转由组内自行保证数据访问的正确性。下图给出了组锁的处理逻辑。组锁机制的使用,避免了数据锁反复分配造成的性能损失,将有可能对某些并行应用的I/O性能具有巨大的提升作用。获得组锁的动作由ll_get_grouplock()函数完成,该函数会调用cl_get_grouplock()函数完成具体的工作。在cl_get_grouplock()函数中,会将cl_lock_descr对象的cld_gid字段设置为ioctl参数中所指定的组ID,将范围设置为[0,
EOF]。当组外的进程想要访问或修改该文件时,由于其他模式的锁与组锁不兼容,从而导致了文件操作的阻塞或失败。这种兼容性的检查发生在函数ldlm_extent_compat_queue()中,在此时,如果不允许阻塞时,该函数直接返回EWOULDBLOCK错误。我们可以看到,组锁模式是一种旨在获得性能提升的额外模式,它的缺失并不影响分布式文件系统功能的正常,因此在Lustre的低版本中找不到组锁的身影。

LCK_COS。COS(Commit on Sharing)是这样一种机制,它发生资源共享时,提交之前对该资源的修改事务,以减少系统恢复时客户端间的依赖,提高系统的可靠性。事务(Transaction)是一种原子操作,它是对包括文件、目录属性、目录项、文件内容在内的文件系统状态的一次读取和修改。我们知道,为了提高性能,在操作返回完成时,对文件系统的修改事件并没有提交到非易失的存储设备中。这就可能造成未提交事件间的依赖关系,例如事务B在事务A修改某个对象后读取该对象,那么事务B就依赖于事务A,但是此时事件A可能并未被提交。如果事务A和B分别由不同的客户端发起,就造成了客户端间存在相互依赖的事务。这种依赖在一切正常的时候并无甚不妥,然而在恢复(Recovery)时却有可能会造成客户端被驱逐事件的连串发生。在服务器停机并重启之后,就进入恢复状态,客户端将重新建立连接,并重新应用它们的请求。如果所有客户端未提交的事务都能被正确地处理,而客户端上的高速缓存都能被刷新为有效(Revalidate),那么这次恢复过程就宣告成功。然而,如果恢复失败,那么客户端就会被驱逐(Evicted),也就是被迫清除所有的高速缓存,并对所有未完成的事务返回失败。在存在客户端间的事务依赖时,如果被依赖的客户端错过了恢复窗口,那么就可能造成其他客户端被迫放弃某些事务甚至整个客户端被驱逐,进而有可能导致众多客户端多米诺骨牌式地被驱逐。为了解决这个问题,Lustre引入了COS锁来减少跨客户端的事务依赖。在对象被COS模式的锁保护时,同一个客户端对之进行的上锁操作会被批准,而另外一个客户端的上锁操作则会与之发生冲突,冲突一旦发生,就会引发事务的提交。这种特殊的兼容性处理可以在ldlm_inodebits_compat_queue()中找到。该函数是被ldlm_process_inodebits_lock()函数调用的。ldlm_process_inodebits_lock()函数在ldlm_inodebits_compat_queue()函数的返回值表明当前锁不兼容时,会调用ldlm_run_ast_work()函数来调用回调函数。在新锁与旧的COS锁冲突的情况下,ldlm_run_ast_work()函数将选择调用阻塞型回调。这个阻塞型回调函数是在mdt_fid_lock()函数调用ldlm_cli_enqueue_loca()函数时,被设置为mdt_blocking_ast()函数的。mdt_fid_lock()函数是被mdt_object_lock()函数调用的。mdt_blocking_ast()函数所做的处理是将调用mdt_device_commit_async()函数把事务提交到存储设备中。在MDS修改一个对象前会调用mdt_object_lock()对该对象上锁,修改完毕后,对应地调用mdt_object_unlock()对之解锁。在mdt_object_unlock()被调用时,并不是简单地解除锁,而是调用ldlm_lock_downgrade()函数将锁从LCK_PW和LCK_EX模式转化成LCK_COS模式。这样,在下一次mdt_object_lock()函数上锁时,来自其他客户端的上锁操作将会与旧的COS锁相冲突,进而引发事务的提交。需要指出的是,COS虽然可以增加系统可靠性,但是却可能造成性能的降低,因此COS是一个可选特性,可由lctl
set_parammdt.*.commit_on_sharing=1命令使能。



图 LDLM组锁的处理逻辑
LDLM之所以区分众多的锁模式,是为了通过定义它们之间的兼容关系,在保护资源的前提下,达到最大程度地共享资源的目的。例如,我们很容易地想到,相比于平凡锁,通过区分读写锁,既可以确保数据写入时的一致性,也可以让数据得以并行读出。LDLM则更进一步,定义了更多模式的分布式锁,并确定了如下所示的的兼容关系:

EX
PW
PR
CW
CR
NL
GROUP
COS
EX
0
0
0
0
0
1
0
0
PW
0
0
0
0
1
1
0
0
PR
0
0
1
0
1
1
0
0
CW
0
0
0
1
1
1
0
0
CR
0
1
1
1
1
1
0
0
NL
1
1
1
1
1
1
1
0
GROUP
0
0
0
0
0
1
1
0
COS
0
0
0
0
0
0
0
1

图 LDLM各种锁模式的兼容关系
它们的关系矩阵是一个对称矩阵,因此我们知道它们之间的兼容关系是对称关系。对于锁兼容关系的对称性,我们可以这样理解:如果A模式的锁与B模式的锁兼容,则意味着,如果某资源已被A模式的锁保护,则请求对该资源加上B模式的新锁会被批准;同时也意味着,如果资源已被B模式的锁保护,则请求对该资源加上A模式的新锁也会被批准;反之亦然。

我们还可以把各种锁模式分成两个阵营,一个是读锁阵营,包括LCK_NL、LCK_CR和LCK_PR,另外一个是写锁阵营,包括LCK_EX、LCK_CW、LCK_PW、LCK_GROUP和LCK_COS。这样划分的依据可以参考对锁添加引用的ldlm_lock_addref_internal_nolock()函数。对于使用类型ldlm_lock描述的锁对象,该函数区分了锁模式,分别选择对其l_readers字段或l_writers字段增加计数。

通过各种模式的锁的使用方式和它们的兼容关系,我们可以分析出Lustre并发操作的行为模式。对于锁模式及其兼容关系的实际应用,我们将在下面的内容中,结合具体实例进行分析。

4.4 锁管理服务

从锁管理者处请求锁的部件是LDLM客户端,LDLM客户端可能是MGC、MDC、OSC或者Lustre Lite。

我们在Portal RPC一章已经提到,ldlm_setup()函数作为服务启动函数的一种,会创建对应的消息处理线程池。在这里,创建了两类消息处理线程池。其中一组的名字前缀为ldlm_cbd,它的请求输入portal是LDLM_CB_REQUEST_PORTAL,而请求输出portal是LDLM_CB_REPLY_PORTAL,运行的处理函数是ldlm_callback_handler()。而另一组的名字前缀为ldlm_canceld,它的请求输入portal是LDLM_CB_REQUEST_PORTAL,而请求输出portal是LDLM_CB_REPLY_PORTAL,运行的处理函数是ldlm_callback_handler()。

我们可以发现,与mgs_setup()、mdt_start_ptlrpc_service()、ost_setup()等其他服务启动函数不同的,ldlm_setup()函数不仅会被服务端调用,而且会被客户端调用,因为它们两者都需要参与分布式锁的管理。

4.5 锁的获取

LDLM锁的获取者一般是Lustre客户端,如MGC、MDC和OSC,但也可能是Lustre服务端,如MGS、MDT和OST。下图给出了前一种情况下获取锁的流程。



图 LDLM锁的获取流程
在锁获取发起者是Lustre客户端的情况下,客户端像服务器发送一个锁获取请求。Lustre服务器接收并处理请求之后,回复一种回调函数消息。该消息被Lustre客户端接收,引发回调函数的调用,完成了上锁的整个流程。接下来我们来细致地分析这个流程。

4.5.1 锁请求的发送

MGC、MDC、OSC的锁获取函数分别为mgc_enqueue()、mdc_enqueue()、osc_enqueue(),这些函数有统一的后缀,因为它们都会被赋值给其OBD设备操作方法列表obd_ops的o_enqueue字段(除了mgc_enqueue()函数之外)。这几个函数的流程类似,它们都需要向对应的服务端发送锁获取请求。因此,它们都新建一个由ptlrpc_request 对象描述的Portal RPC请求,这个请求的格式可以为以下之一:

RQF_LDLM_ENQUEUE_LVB。osc_enqueue()函数在有获得LVB(Lock ValueBlock)的意图时,新建该格式的请求。mdc_enqueue()函数在有获得文件属性、获得分条属性、查找目录项时的意图时,由mdc_intent_getattr_pack()函数新建该格式的请求。关于意图和LVB,我们将在下面的内容进行分析。

RQF_LDLM_INTENT_OPEN。mdc_enqueue()函数在有打开文件的意图时,由mdc_intent_open_pack()函数新建该格式的请求。

RQF_LDLM_INTENT_UNLINK。mdc_enqueue()函数在有删除文件的意图时,由mdc_intent_unlink_pack()函数新建该格式的请求。

RQF_LDLM_ENQUEUE。mgc_enqueue()函数新建该格式的请求。mdc_enqueue()在有读取目录内容的意图时,也由ldlm_enqueue_pack()函数新建该格式的请求。osc_enqueue()函数在没有意图时,会对ldlm_cli_enqueue()函数的参数传入的请求指针值为NULL,ldlm_cli_enqueue()会据此新建一个该格式的请求。

在新建好请求后,mgc_enqueue()、mdc_enqueue()、osc_enqueue()会调用ldlm_cli_enqueue()函数把锁请求发送出去。这些函数都选择将ldlm_cli_enqueue()函数的async参数设置为0,从而要求该函数调用ptlrpc_queue_wait()函数等待请求发送完成,且接受到请求后才返回。ldlm_cli_enqueue()函数还接受一个ldlm_enqueue_info类型的参数。它的ei_type字段是锁的类型,ei_mode字段是锁的模式,还有一些以ei_cb_作为前缀的字段是锁的回调函数指针,ei_cbdata是这些回调函数的参数。对于回调函数我们将在接下来的内容中进行分析。

ldlm_cli_enqueue()函数的流程如下:

1. 如果参数表明此次获取锁是重放(replay)这个锁,那么说明锁已经存在,只需要从句柄参数获得由ldlm_lock描述的锁对象。否则调用ldlm_lock_create()函数新建一个锁对象。

2. 如果输入的参数没有给定请求指针,那么新建一个RQF_LDLM_ENQUEUE格式的请求。

3. 将锁对象的信息填入请求缓冲中。

4. 如果请求是本函数新建的格式为RQF_LDLM_ENQUEUE,并且输入参数表明需要获得LVB,那么将请求格式扩充为RQF_LDLM_ENQUEUE_LVB。

5. 如果输入参数表明是异步处理过程,则直接返回,否则等待请求的完成。获得了请求处理状态

6. 调用ldlm_cli_enqueue_fini()函数,根据请求处理状态从请求回复中获取所需信息。如果请求处理状态表明获取锁成功,且获取锁的过程不是在恢复状态重放,则调用ldlm_lock_enqueue()函数和锁的完成型回调函数。ldlm_lock_enqueue()函数和回调函数将在后面的内容中予以介绍。

4.5.2 锁请求的处理

服务端接收到LDLM_ENQUEUE类型的锁获取请求之后,进行各自的处理。mgs_handle()函数、osc_handle()函数将调用ldlm_handle_enqueue()函数处理获取锁请求。ldlm_handle_enqueue()函数调用ldlm_handle_enqueue0()函数完成具体的工作。而MDT端的mdt_regular_handle()函数则封装得更为复杂,但是它最终也是调用ldlm_handle_enqueue0()处理锁获取请求。预计在今后的版本中,OST和MGS的请求处理函数也会被升级成类似MDT的形式。

ldlm_handle_enqueue0()函数是处理锁请求的主要函数,其主要流程如下:

1. 调用ldlm_lock_create()函数创建一个ldlm_lock类型的锁对象。

2. 调用ldlm_lock_enqueue()函数尝试获取新锁。我们可以看到,在一次上锁流程中,ldlm_lock_enqueue()函数不仅被服务端调用,也被客户端调用,只不过它们作用的命名空间一个是服务端命名空间,一个是影子命名空间。

3. 调用lustre_pack_reply()函数准备好回复消息。该消息将最终由请求处理函数发送到客户端。

ldlm_lock_enqueue()函数是批准锁的核心步骤,其流程如下:

1. 如果锁所属的名字空间是服务端名字空间,且锁获取操作存在意图,且上锁的过程不是在恢复状态下重放,则调用名字空间的意图处理函数。如果意图处理函数返回成功,则返回。如果意图处理函数成功,且请求中表明只需要处理意图,而不需要上锁,也返回。关于意图处理函数将在下面的内容中进行介绍。

2. 如果锁所属的名字空间是客户端端命名空间,则根据函数的输入参数flags决定是调用ldlm_resource_add_lock()函数将该锁加入等待队列,还是加入转换队列,抑或是调用ldlm_grant_lock()函数批准这个锁。在该情况下,不管flags是何值,该函数返回。这是一个客户端的获取锁处理,此时flags参数来自于服务端的指示,客户端只需按照其指示行事。

3. 如果锁所属的名字空间是服务端命名空间,且上锁是在恢复状态下重放,那么也将做与第2步类似的处理,由输入参数flags决定是调用ldlm_resource_add_lock()函数将该锁加入等待队列,还是加入转换队列,抑或是调用ldlm_grant_lock()函数批准这个锁。但与第2步不同的是,如果输入参数flags有明确指示,该函数返回,否则该函数将继续处理。这是一个在服务端进行的恢复处理,此时flags参数来自于客户端的指示,服务端不得不相信客户端没有欺骗自己。

4. 调用锁类型对应的策略函数。

4.5.3 意图及意图处理函数

意图(Intent)是在上锁操作时,提供的一些额外信息,用来告知服务器在上锁过程中需要做的特殊处理。意图的特性使得客户端在上锁时可以向服务器传递一些关于它们最终想实现什么目的的信息。意图的网络效应是减少了客户端和服务器交互时的RPC数目。

意图处理函数(Intent Handler)是为了处理这些意图而被注册在不同的名字空间的函数。名字空间描述对象ldlm_namespace的ns_policy字段就是意图策略函数的指针。意图处理函数的注册是在服务初始化时,通过调用ldlm_register_intent()函数注册来完成的。例如mdt_init0()函数将其注册为mdt_intent_policy()函数,filter_common_setup()函数将其注册为filter_intent_policy()函数。在服务端处理上锁请求时,会调用ldlm_lock_enqueue()函数。该函数在一开始就会调用意图处理函数。

发往MDS的请求的意图可以区分不同的类型,包括:打开、创建并打开、创建、取得属性、读目录、查找、删除、截断、取得扩展属性、取得分条属性。mdt_intent_policy()函数则会根据意图类型调用相应类型的策略函数,如mdt_intent_reint()函数或mdt_intent_getattr()函数。

对于发往OST的上锁请求,其意图只可能有一种,就是在一瞥型上锁操作时尝试获取LVB信息的意图。LVB是一种关于数据对象的信息。这些信息包括数据对象的大小,时间戳和数据块数,它由ost_lvb对象描述。我们都知道,Lustre文件的数据是按照偏移量分布位于不同OST的多个数据对象上的。为了获得文件时间戳、大小和数据块数,客户端需要从MDS获取它所记载的时间戳,同时从所有OST获取数据对象的LVB。从这些LVB的数据大小,lov_merge_lvb()函数可以组合出文件的最近可见大小(RSS,Recently
Seen Size),并被ll_merge_lvb()函数(其实也就是cl_merge_lvb()函数)设置为文件的大小。ll_merge_lvb()函数还调用lov_merge_lvb()函数,从MDS获得时间戳和各LVB的时间戳中获得时间戳的最晚者,分别作为文件的修改时间戳、访问时间戳和创建时间戳。与RSS相比,lov_merge_lvb()函数还可以获得关于文件长度的一个更为重要的估计:已知最小大小(KMS,Known Minimum
Size)。KMS是当前客户端因为已经获得了锁,从而可以确定的文件的最小大小,是对文件长度的谨慎估计。ccc_prep_size()函数首先通过KMS来简单判断短读(short
read)情况。短读指得是文件读的区间[offet, offset +count)有一部分超出了文件的最大长度。在ccc_prep_size()函数中,它首先判断offet
+ count - 1的值是否大于KMS,如果答案为是,那么客户端并不能确定不会出现短读,所以需要调用cl_glimpse_lock()来获取文件的新的大小,以确保此次数据的读入不会由于文件大小过小而被截短。

对获取LVB信息的意图,其策略函数是filter_intent_policy()。该函数的流程如下:

1. 调用该类型的锁对应的策略函数。如果策略函数返回值表明已毫无阻碍地批准了锁,那么则该返回成功。如果策略函数返回值表明需要等待,而请求又是一个异步一瞥型锁请求(AGL,Async GlimpseLock),那么本函数就不再进行后续的处理,而返回失败。cl_glimpse_size0()函数的agl参数就是用来表明一瞥型锁操作是异步还是同步的标志。

2. 从已批准对该资源加上的所有LCK_PW锁中选取一个锁,这个锁的文件长度大于比当前LVB表明的长度,而且是所有满足这个条件的所有锁中的长度最大者。如果没有找到任何满足这一条件的锁,返回失败。

3. 对该锁调用一瞥型回调函数。这个一瞥型回调函数是ldlm_server_glimpse_ast()。该函数将向锁的持有者发送一个格式为RQF_LDLM_GL_CALLBACK操作码为LDLM_GL_CALLBACK的请求。在接受到回复后,该函数就可以从中取得该资源的LVB。该函数随后调用ldlm_res_lvbo_update()函数从这个LVB信息和本地存储器中存储的信息选取最大者作为最终值。

4.5.4 策略函数

正如上面提到的,ldlm_lock_enqueue()函数将会根据锁的类型调用对应的策略函数。平凡锁、范围锁、Flock锁和索引节点比特锁等四种类型的锁分别对应ldlm_process_plain_lock()函数、ldlm_process_extent_lock()函数、ldlm_process_flock_lock()函数和ldlm_process_inodebits_lock()函数。这四个函数的类型都为ldlm_processing_policy,它们会被存储在ldlm_processing_policy_table的函数指针表中。这些函数的流程整体流程类似,只有一些微小变化。

这些函数接受一个first_enq参数。这个参数用来标志锁是否首次入队列。如果是首次,那么在与旧锁冲突的时候需要发送阻塞AST。如果不是第一次,我们已经在先前发送了阻塞AST,不需要再重复发送了。first_enq参数在ldlm_lock_enqueue()函数中被设置为1,在ldlm_reprocess_queue()函数和ldlm_lock_convert()函数中被设置为0。

在分析策略函数之前,我们首先要理解的一个重要概念是资源。获取LDLM锁的目的是为了保护某个资源。每个资源都由类型为ldlm_resource的一个对象来描述。这个对象记录了对该资源的所有锁的状态信息。其中三个字段lr_granted、lr_converting、lr_waiting链表,分别是批准链表(Granted List)、等待链表(Waiting List)和转换链表(Converting List)。批准链表保存了已被准许在该资源上使用的所有锁。等待链表保存了正在请求该资源,但是由于冲突而需要等待的所有锁。转换链表保存了处在转换状态的所有锁。

在策略函数中,为了判别当前申请获取的锁是否可被批准,需要首先扫描整个批准链表,如果前申请获取的锁与批准链表中的某个锁冲突,则该锁不能被批准。接着扫描等待链表,如果申请获取的锁与之前的某个锁冲突,则该锁也不能被批准。需要注意的是,对于不是首次入队列的上锁请求,也就是当first_enq标识为0时,该锁在上一次调用策略函数时,就已经被放入等待链表中,本次调用策略函数检查冲突,不需要扫描整个等待链表,而是在等待链表中找到自己的先前入口后,就停止检查。这是因为在等待链表中排名靠前的锁比靠后的锁更为优先。

各策略函数分别调用以ldlm_*_compat_queue()为名字的函数检查当前锁与各旧锁的兼容性(但ldlm_process_flock_lock()函数没有对应的ldlm_*_compat_queue()函数)。虽然各种类型的兼容性检查函数在实现上存在差异,但大体流程一致。它们以批准链表或等待链表作为参数,对链表中的各个锁,执行以下检查:

1. 如果旧锁就是当前的锁,则返回兼容。这发生在如前所述的等待链表中已包含当前的锁的情况下。

2. 调用lockmode_compat()函数检查新锁与旧锁是否兼容,如果兼容则返回成功。

3. 对于范围锁,即使锁模式不兼容,只要锁范围并未交叉,那么并不存在冲突。事实上,为了快速检查范围锁间是否存在冲突,范围锁使用一组范围树来存储所有已批准锁的覆盖范围。每棵范围树对应了所有已批准的某种模式的锁。新的范围锁如果想被批准,则不能与模式不兼容的任何范围树的范围交叉。我们应该注意到,范围树只使用在批准队列中,而没有使用在等待队列中。对于索引节点比特锁,只有请求的比特与旧锁重叠了,才返回冲突。对于flock锁,只有请求的范围与旧锁范围重叠了,才返回冲突。对于平凡锁,只要模式与某个旧锁不兼容,就返回冲突。

4. 如果输入参数中的工作队列为空,则返回冲突。这种情况发生在如前所述的新锁并非首次入队列时。

5. 如果锁定义了阻塞型回调函数,则调用ldlm_add_ast_work_item()函数将冲突的旧锁加入工作队列中。

在上述流程在批准链表和等待链表上执行都未找到冲突情况时,就可以调用ldlm_grant_lock()函数批准该锁了。对于范围锁,在分条大小的限制之内,LDLM还尝试尽其所能地批准最大的范围,以减少客户端将来为请求更多的锁而产生的RPC。最大可批准范围由在ldlm_grant_lock()函数之前调用的ldlm_extent_policy()确定。

如果兼容性检查失败,那么锁将不被批准。如果锁在首次入队列时不被批准,还需要进行额外处理:

1. 调用ldlm_resource_add_lock()函数将当前锁加入等待队列中。

2. 调用ldlm_run_ast_work()函数处理工作队列。工作队列中保存了所有对当前锁造成阻塞的旧锁。在这个函数中将对所有这些锁调用阻塞型回调函数。调用阻塞型回调函数时,其标志参数被设置为LDLM_CB_BLOCKING。

4.5.5 回调函数

我们在前面的内容中已经多次接触到了回调函数这个概念。这里我们对回调函数进行专门的分析。我们曾经在Portal RPC一章也接触到了一种回调函数。我们应当区分它们之间的不同。回调(callback)又被称为AST(AsynchronousSystem Trap),它分为以下三种:

LDLM_BL_CALLBACK,阻塞型回调(Blocking Callback)。这种回调被调用的情况有两种,分别对应了调用该函数时可以设置的两种标志,一种是LDLM_CB_BLOCKING,另一种是LDLM_CB_CANCELING。第一种发送在这种情况:当前客户端持有某个锁,而又另有客户端请求一个与当前锁相冲突的锁(即使请求发自同一个客户端),那么服务端将向当前客户端发送一个操作码为LDLM_BL_CALLBACK的消息。当前客户端的ldlm_callback_handler()函数调用ldlm_handle_bl_callback()函数处理该类型的回调。如果ldlm_handle_bl_callback()函数发现这个锁没再被引用,即l_writers和l_reads计数都为零,它就可以调用阻塞型回调函数,释放该锁,以便他人上锁。此外,在一瞥型回调时,ldlm_handle_bl_callback()也有可能被ldlm_handle_gl_callback()函数调用。第二种情况是当锁被撤销时(在所有的引用都撤销了,而锁也被撤销之后),被ldlm_cancel_callback()函数调用。关于锁的撤销将在后面分析。

LDLM_CP_CALLBACK,完成型回调(Completion Callback)。这种回调被调用有两种可能情况。第一种,获取锁的请求被批准。第二种,锁被转化了,例如转化为另一种模式的锁。在客户端,当锁被批准时,ldlm_cli_enqueue_fini()函数或ldlm_cli_enqueue_local()函数会调用完成型回调。而在服务端,锁被撤销也可能引发先前被阻塞的锁被批准,进而引发完成型回调被调用。这一点将在分析锁的撤销时进行详细分析。在第二种情况下,ldlm_cli_convert()函数也会调用完成型回调。

LDLM_GL_CALLBACK,一瞥型回调(Glimpse Callback)。这种回调用来提供某些基本性质的信息,而不用实际上释放锁。例如,由于只有OST知道文件对象的确切大小,OST需提供一个取得文件信息的回调。在服务端,当接收到客户端的带有一瞥型意图的上锁请求时,该回调将被调用。而在服务端处理完带有一瞥型意图的上锁请求,也会向客户端发送一瞥型回调请求,从而引发客户端一瞥型回调函数被调用。

在前面我们提到分布式锁由类型为ldlm_lock的对象描述。这个对象中的l_blocking_ast、l_completion_ast、l_glimpse_ast字段就是回调函数的指针。在锁的创建函数ldlm_lock_create()里,这些字段被赋值。

回调函数可以分为客户端的回调函数和服务端的回调函数。客户端的回调函数由ldlm_cli_enqueue()函数注册。在早前的内容中,我们分析到在ldlm_cli_enqueue()函数接受到锁被批准的回复时,会调用完成型回调函数。这是客户端回调函数的一种用法。

而服务端的回调函数则是在调用ldlm_handle_enqueue0()函数之前,由各请求处理函数注册。下面我们来分析一些情况下注册的回调函数。

表 各函数注册的LDLM回调函数

函数

回调函数
mgs_handle()

完成型回调:ldlm_server_completion_ast()

阻塞型回调:ldlm_server_blocking_ast()

一瞥型回调:NULL

mdt_regular_handle()

完成型回调:ldlm_server_completion_ast()

阻塞型回调:ldlm_server_blocking_ast()

一瞥型回调:NULL

ost_handle ()

完成型回调:ldlm_server_completion_ast()

阻塞型回调:ost_blocking_ast()

一瞥型回调:ldlm_server_glimpse_ast()

mgc_enqueue()

完成型回调:ldlm_completion_ast()

阻塞型回调:mgc_blocking_ast()

一瞥型回调:NULL

mdc_enqueue()

完成型回调:ldlm_completion_ast()

阻塞型回调:NULL

一瞥型回调:NULL

osc_lock_init()

完成型回调:osc_ldlm_completion_ast()

阻塞型回调:osc_ldlm_blocking_ast()

一瞥型回调:osc_ldlm_glimpse_ast()

我们对这些函数进行简要的分析:

ldlm_server_completion_ast()函数。该函数在ldlm_reprocess_all()、ldlm_handle_cp_callback()和ldlm_lock_convert()函数中被调用。在锁被撤销引发原来被阻塞的锁被批准时,该函数也被调用。该函数将向锁的请求者发送操作码为LDLM_CP_CALLBACK的消息。

ldlm_server_blocking_ast()函数。在策略函数一节我们知道,在新锁不批准时,将会调用这个策略函数,处理所有造成该锁被阻塞的锁。该函数向持有锁的客户端发送一个格式为RQF_LDLM_BL_CALLBACK操作码为LDLM_BL_CALLBACK的请求。一般来说,客户端如果发现该锁无用,会通过锁撤销流程撤销该锁。该函数通过RPC服务线程异步发送该请求,且不等待请求的回复。如果这个函数以LDLM_CB_CANCELING为标志被调用,它不做以上的任何处理,直接返回成功。

ost_blocking_ast()函数。该函数的调用时机与ldlm_server_blocking_ast()函数相同。事实上,该函数最终调用了ldlm_server_blocking_ast()函数作为处理流程的一部分。只不过在调用此函数之前,如果ost_blocking_ast()函数以LDLM_CB_CANCELING为标志被调用,则可能会调用obd_sync()函数把数据同步到存储设备中去,因为此时在撤销锁。

ldlm_server_glimpse_ast()函数。我们前面已经知道,在客户端需要查询文件的长度等属性时,会引发该函数的调用。这个函数在前面已经介绍过,这里不再赘述。

mgc_blocking_ast()函数。如果该函数的输入参数标志是LDLM_CB_BLOCKING,那么意味这MGS想收回该锁,该函数会直接调用ldlm_cli_cancel()函数撤销这个锁。如果输入标志是LDLM_CB_CANCELING,那么正如在介绍平凡锁时所提到的,mgc_blocking_ast()函数将会引发mgc_requeue_thread()线程重新调用mgc_process_log()函数更新系统配置。需要注意的是,ldlm_cli_cancel()函数也会以LDLM_CB_CANCELING标志调用阻塞型回调函数,因此也会最终引发配置的更新。利用这种方式,MGC巧妙地保持了配置的动态跟新。

osc_ldlm_completion_ast()。该函数是在ldlm_cli_enqueue_fini()函数上锁成功时调用的。这个函数做的主要工作包括调用osc_lock_lvb_update()函数更新LVB信息和通知上层上锁成功等。

osc_ldlm_blocking_ast()函数。该函数是在有其他客户端请求获取与本锁冲突的新锁时被调用的。该函数将最终调用osc_lock_cancel()函数来撤销这个锁。osc_lock_cancel()函数首先调用osc_lock_flush()函数把所有被该锁保护的脏页刷回服务器,并从内存中丢弃被该锁保护的所有页高速缓存。接着,osc_lock_cancel()函数函数调用ldlm_cli_cancel()函数撤销该锁。

osc_ldlm_glimpse_ast()函数。服务端的filter_intent_policy()函数在处理完意图之后之后,会发送一瞥型回调请求到客户端。该请求被ldlm_cbd线程池捕获,并被ldlm_callback_handler()函数处理。该函数调用ldlm_handle_gl_callback()处理这种回调请求。这个函数一方面调用一瞥型回调,也就是客户端的osc_ldlm_glimpse_ast()函数,然后也在锁没有人使用时调用ldlm_handle_bl_callback()函数把它释放掉。osc_ldlm_glimpse_ast()函数锁做的主要工作是告知上层,一瞥型操作成功返回。

4.5.6 客户端的阻塞型回调

前面讲到在上锁操作被阻塞时,服务端会对阻塞新锁的所有锁调用ldlm_server_blocking_ast()函数。该函数将向旧锁的所有者发送格式为RQF_LDLM_BL_CALLBACK操作码为LDLM_BL_CALLBACK的请求。我们下面接着这个流程,分析客户端对这个消息的处理。

这个请求被目标客户端的ldlm_cb守护线程池捕获。该线程池调用ldlm_callback_handler()来处理该请求。对这种类型的请求,ldlm_callback_handler()函数调用ldlm_handle_bl_callback()函数来尝试撤销本客户端持有的锁。ldlm_handle_bl_callback()函数检查锁是否正被使用,即ldlm_lock对象的l_readers和l_writers字段是否都为零。如果没有被使用,则该函数将以LDLM_CB_BLOCKING为标志调用锁的阻塞型回调函数。这个阻塞型回调函数早在锁新建时就被客户端注册好了。它将完成锁被阻塞时所需做的具体工作,一般来说,它会撤销这个对自己已无用的锁。

4.5.7 服务端的锁请求

我们需要注意到,上锁操作不仅可能发生在客户端,而且可能发生在服务端。服务端的上锁操作又可以分成两种情况,其中一种是该服务端恰好是该资源的所属服务器,这时调用的上锁函数是ldlm_cli_enqueue_local()。在另外一种情况下,服务端请求对另一个服务端所属的资源上锁。这种情况很少发生,只出现在mdt_rename_lock()函数需要对远程的MDT上的文件上锁时,此时本服务器将像一个普通客户端一样调用ldlm_cli_enqueue()函数。

由于要上锁的资源就是本服务器进行管理的,因此ldlm_cli_enqueue_local()不需要发送RPC消息,而只需要进行两步操作:首先创建一个锁,然后调用ldlm_lock_enqueue()来检查这个锁是否可以被批准。如果ldlm_lock_enqueue()函数的返回值表明锁未被批准,则函数返回。如果锁被批准,那么就调用锁的完成型回调函数。

4.6 锁的撤销

锁的撤销通常是非自愿的,持有者会尽其所能地长时间持有锁,直到它不得不撤销该锁。但在一些情况下,持有者也会自愿地撤销锁。锁的撤销发生在以下的情况下:

当客户端接收到锁阻塞AST请求时。在前面的内容中,我们提到在持有锁的客户端收到来自服务器的阻塞型回调请求之后,如果发现该锁已不被本客户端使用,就会撤销该锁。

当内核的页框回收算法发现需要调用shrink_slab()函数从可压缩内核高速缓存中回收页框时。在LDLM服务初始化时,ldlm_pools_init()函数会被调用。该函数向内核注册两个shinker函数,使得内核可以通过调用这两个shinker函数回收一些页框。在回收页框时,ldlm_cancel_lru()函数将尝试以最近最少使用(LRU,Least Recently Used)为策略选取并撤销一些未被使用的锁。

当删除索引节点时。在ll_delete_inode()函数被调用时,该索引节点对应的对象设备将被cl_object_kill()函数清理。cl_object_kill()所做的事情就是调用cl_locks_prune()函数撤销该索引节点上的所有锁。

当客户端I/O需要提前撤销与新锁冲突的本地锁时。这个撤销过程发生在一般化的上锁函数cl_enqueue()中。该函数调用cl_enqueue_locked()函数完成具体工作。cl_enqueue_locked()函数不断调用cl_enqueue_try()尝试加锁。如果cl_enqueue_try()函数发现本客户端持有一个与新锁冲突的锁,那么cl_enqueue_locked()函数将撤销那个锁。通过这种方式,经过若干循环之后,cl_enqueue_locked()函数就撤销了所有与新锁冲突的本地锁。这样就使得I/O得以顺利进行,而不需浪费RPC了。新锁与本地锁的冲突检测最终由osc_lock_enqueue_wait()函数完成。在OSC的上锁函数osc_lock_enqueue()中会调用osc_lock_enqueue_wait()函数。

当客户端释放对组锁的最后一个引用时。组锁是一种需要用户进程显式获取的分布式锁。对组锁的引用除了会在文件关闭时自动释放外,还可被显式释放。当有进程释放组锁时,将调用cl_lock_hold_release ()函数。该函数若发现锁的持有者数目已变为零,即该客户端上没有其他进程持有该组锁,就调用cl_lock_cancel0()函数撤销该组锁。

当一瞥型锁获取后。一瞥型操作在获得了锁和LVB信息,并计算出所需的文件长度和时间戳等属性之后,立即释放对该锁的引用,从而撤销所获得的锁。

撤销锁的入口是ldlm_cli_cancel()。这个函数的流程为:

1. 调用ldlm_cli_cancel_local()函数,将锁从本地撤销。

2. 将要撤销的锁加入撤销链表。

3. 如果当前配置支持批量撤销(Early Batched Cancels),则调用ldlm_cancel_lru_local()函数。该函数从名字空间中以LRU为策略选取一些无用锁,加入到撤销链表中,并从本地撤销。批量撤销指的是将撤销锁的操作打包若干到一个请求中,以减少RPC。

4. 调用ldlm_cli_cancel_list()函数,把撤销链表中的锁撤销。这个函数将调用ldlm_cli_cancel_req()函数把所有撤销请求发送出去。在支持批量撤销的情况下,该函数将把锁撤销操作打包到一个请求中发出去,否则将把锁撤销请求逐个发送出去。

在ldlm_cli_cancel_local()函数中,将调用ldlm_cancel_callback()函数。该函数以LDLM_CB_CANCELING为标志调用阻塞型回调。阻塞型回调函数早在锁新建时就被客户端注册好了。这些阻塞型回调函数将完成锁撤销前所需做的具体工作,例如,正如在介绍平凡锁时所提到的,mgc_blocking_ast()函数将会引发mgc_requeue_thread()线程重新调用mgc_process_log()函数更新系统配置。

在服务器端,这些锁撤销请求将被ldlm_canceld线程池捕获,由ldlm_handle_cancel()函数处理。具体的撤销操作由ldlm_request_cancel()函数完成。该函数从请求中获得所有需要撤销的锁,并对每个锁调用ldlm_lock_cancel()函数。在ldlm_lock_cancel()函数调用之前,需调用ldlm_res_lvbo_update()函数更新锁保护的资源的LVB,而在ldlm_lock_cancel()函数调用之后需调用ldlm_reprocess_all()函数重新处理资源的转换链表和等待链表。调用ldlm_reprocess_all()函数因为由于本个锁的释放,原来被本锁阻塞的锁有可能可以被批准了,因此可以以LDLM_WORK_CP_AST为标志调用ldlm_run_ast_work()函数来对这些新批准的锁调用完成型回调函数。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: