dot 快速的未来即将在您所在的城市举行活动。

加入我们在 Redis 发布会

如果您认为 Goto 是个坏主意,那么您会如何看待 Longjmp?

我个人不同意永远不要在代码中使用 goto 的传统智慧。在某些情况下,我发现它不仅方便,而且是最佳实践。最常见的情况是 goto cleanup。考虑以下情况

没有 goto

void f(void) {
	void *a = NULL;
	void *b = NULL;
	void *c = NULL;
  
	a = malloc(32);
	//...
	if(cond1) {
		free(a)
		return;
	}
  
	b = malloc(64);
	//...
	if(cond2) {
		free(a);
		free(b);
		return;
	}
  
	c = malloc(128);  
	//...
	free(a);
	free(b);
	free(c);
}

使用 goto

void f(void) {
  	void *a = NULL;
	void *b = NULL;
	void *c = NULL;

	a = malloc(32);
	//...
	if(cond1) goto cleanup;

	b = malloc(64);
  	//...
	if(cond2) goto cleanup;
  
	c = malloc(128);
	//...
cleanup:
	if(a) free(a);
	if(b) free(b);
	if(c) free(c);
}

我们无需在满足条件时跟踪哪些指针需要释放,只需跳转、释放分配的任何内容并返回即可。在我看来,这种设计更简洁,不易出错,但我理解为什么其他人反对它。

最近,我们想引入错误报告来处理在评估表达式时发生的失败。例如,评估静态表达式 toUpper(5) 将失败,因为 toUpper 函数期望其参数为字符串。如果未满足此假设,toUpper 应该抛出异常。

SIValue toUpper(SIValue v) {
  SIType actual_type = SI_TYPE(v);
  if(actual_type != SI_STRING) {
    const char *actual_type_str = SIType_ToString(actual_type);
    raise("Type mismatch: expected string but was %s", actual_type_str);
  }
}

不幸的是,C 没有像许多其他高级语言那样提供内置的异常机制。

if(cond) {
	raise Exception("something went wrong")
}

我们想要的是 try catch 逻辑

try {
  // Perform work which might throw an exception
  work();
} catch (error *e) {
  reportError(e);
}

这种设计的优点是,无论异常是在调用 work 的执行路径中的哪个位置抛出的,堆栈都会自动恢复,我们将在 catch 块中恢复执行。

在我们的例子中,函数 work 被调用 ExecutionPlan_Execute 所取代,该函数实际上评估查询执行计划。从这一点开始,我们必须准备好遇到异常,但是 ExecutionPlan_Execute 在解开和深入时所采取的路径,请考虑以下调用堆栈

redisgraph.so!QueryCtx_SetError (./src/query_ctx.c:78)
redisgraph.so!_AR_EXP_ValidateInvocation (./src/arithmetic/arithmetic_expression.c:220)
redisgraph.so!_AR_EXP_Evaluate (Unknown Source:0)
redisgraph.so!AR_EXP_Evaluate (./src/arithmetic/arithmetic_expression.c:327)
redisgraph.so!_cache_records (./src/execution_plan/ops/op_value_hash_join.c:136)
redisgraph.so!ValueHashJoinConsume (./src/execution_plan/ops/op_value_hash_join.c:201)
redisgraph.so!ProjectConsume (./src/execution_plan/ops/op_project.c:67)
redisgraph.so!SortConsume (./src/execution_plan/ops/op_sort.c:169)
redisgraph.so!ResultsConsume (./src/execution_plan/ops/op_results.c:34)
redisgraph.so!ExecutionPlan_Execute (./src/execution_plan/execution_plan.c:959

执行调用堆栈。

异常是在堆栈的较高位置抛出的,在这种情况下,我们想要

  1. 中止执行,回退 9 帧,一直回退到 ExecutionPlan_Execute
  2. 进入 catch 块

我们可以在执行路径上的每个函数中引入错误检查,但这会降低性能(分支预测)并使我们的代码过于复杂,因为到处都充斥着 if(error) return error; 逻辑结构。

因此,跳跃是想到的第一个选择,但请注意,跳跃只能跳到其被调用的函数中的某个位置。

function A() {
	jump there;	// Can't jump outside of current scope.
}

function B() {
there:
	...
}

我们另一个想法是在一个新线程中调用 ExecutionPlan_Execute,这样当抛出异常时,我们只需终止该线程并在“父”线程中恢复执行。这种方法将使我们无需引入额外的逻辑或代码分支。

function Query_Execute() {
	/* Call ExecutionPlan_Execute on a different thread 
	 * and wait for it to exit */
	char *error = NULL;
	pthread_t thread;
	pthread_create(&thread, NULL, ExecutionPlan_Execute, NULL);
	pthread_join(thread, &error);
	
	if(error != NULL) {
		// Exception been thrown.
		reportError(error);
	}
	...
}

但是这种设计会引入额外的线程执行开销(即使我们使用线程池),我们不想放弃对操作系统调度器的太多控制。

最终,我们发现了 longjmp,它类似于 jump,但不受调用函数范围的限制。我们可以简单地从任何地方跳到调用堆栈中的某个预设点,最棒的是,我们的堆栈会像从每个嵌套函数中返回一样解开到该点。如果你愿意,这就像时光倒流。

// ExecutionPlan.c
function Query_Execute() {
	/* Set an exception-handling breakpoint to capture run-time errors.
	 * encountered_error will be set to 0 when setjmp is invoked, and will be nonzero if
	 * a downstream exception returns us to this breakpoint. */
	QueryCtx *ctx = pthread_getspecific(_tlsQueryCtxKey);
	if(!ctx->breakpoint) ctx->breakpoint = rm_malloc(sizeof(jmp_buf));
	int encountered_error = setjmp(*ctx->breakpoint);
  
	if(encountered_error) {
		// Encountered a run-time error; return immediately.
		reportError();
		return;
	}
	
	/* Start executing, if an exception is thrown somewhere down the road
	 * we will resume execution at: if(encountered_error) above. */
	ExecutionPlan_Execute();
}

/* ArithmeticExpression.c
 * AR_EXP_Evaluate is called from various points in our code base 
 * all originating from Query_Execute. */
SIValue AR_EXP_Evaluate(AR_ExpNode *root, const Record r) {
	SIValue result;
	AR_EXP_Result res = _AR_EXP_Evaluate(root, r, &result);
	if(res != EVAL_OK) {
		/* An error was encountered during evaluation!
		 * Exit this routine and return to the point on the stack where the handler was
		 * instantiated. */
		jmp_buf *env = _QueryCtx_GetExceptionHandler();
		longjmp(*env, 1);
	}
	return result;
}

这是我们最近引入的设计。如果您曾经运行了一个违反了已调用函数假设的查询,此机制将用于报告错误。

127.0.0.1:6379> GRAPH.query G "match (a:person) where toUpper(a.name) = 'Alexander' RETURN a"
(error) Type mismatch: expected String but was Integer

通过 redis-cli 进行错误报告。

出于好奇,我在 cpython github 仓库(Python 实现)中搜索了 longjmp。我想知道他们是否像我们一样将相同的方案应用于异常处理,但是我的搜索没有结果,我需要进一步调查。